diff --git a/.changes/next-release/feature-3c0c9d0841cb39ac6fabcbc085d11f94ce6def67.json b/.changes/next-release/feature-3c0c9d0841cb39ac6fabcbc085d11f94ce6def67.json new file mode 100644 index 00000000000..8bb30f9563e --- /dev/null +++ b/.changes/next-release/feature-3c0c9d0841cb39ac6fabcbc085d11f94ce6def67.json @@ -0,0 +1,7 @@ +{ + "type": "feature", + "description": "Added protocol tests for event streaming in restJson1.", + "pull_requests": [ + "[#2803](https://github.com/smithy-lang/smithy/pull/2803)" + ] +} diff --git a/.changes/next-release/feature-783557f3624987ca34a0269dc18e0b8aa6e4a19f.json b/.changes/next-release/feature-783557f3624987ca34a0269dc18e0b8aa6e4a19f.json new file mode 100644 index 00000000000..70e34c839cc --- /dev/null +++ b/.changes/next-release/feature-783557f3624987ca34a0269dc18e0b8aa6e4a19f.json @@ -0,0 +1,7 @@ +{ + "type": "feature", + "description": "Added support for (de)serializing byte arrays to/from base64-encoded string nodes to `NodeMapper`.", + "pull_requests": [ + "[#2803](https://github.com/smithy-lang/smithy/pull/2803)" + ] +} diff --git a/.changes/next-release/feature-b1c486e0513a86003dcfc7a64a7308e31c52fdbe.json b/.changes/next-release/feature-b1c486e0513a86003dcfc7a64a7308e31c52fdbe.json new file mode 100644 index 00000000000..85e2a598889 --- /dev/null +++ b/.changes/next-release/feature-b1c486e0513a86003dcfc7a64a7308e31c52fdbe.json @@ -0,0 +1,7 @@ +{ + "type": "feature", + "description": "Added a new `eventStreamProtocolTests` trait to enable writing shared test suites for event streams just as has been done for standard HTTP protocol requests and responses.", + "pull_requests": [ + "[#2803](https://github.com/smithy-lang/smithy/pull/2803)" + ] +} diff --git a/smithy-aws-protocol-tests/model/restJson1/event-stream.smithy b/smithy-aws-protocol-tests/model/restJson1/event-stream.smithy new file mode 100644 index 00000000000..83ebbfeedb2 --- /dev/null +++ b/smithy-aws-protocol-tests/model/restJson1/event-stream.smithy @@ -0,0 +1,2338 @@ +$version: "2.0" + +namespace aws.protocoltests.restjson + +use aws.protocols#restJson1 +use aws.protocoltests.shared#DateTime +use smithy.test#InitialHttpRequest +use smithy.test#InitialHttpResponse +use smithy.test#eventStreamTests + +@streaming +union EventStream { + headers: HeadersEvent + blobPayload: BlobPayloadEvent + stringPayload: StringPayloadEvent + structurePayload: StructurePayloadEvent + unionPayload: UnionPayloadEvent + headersAndExplicitPayload: HeadersAndExplicitPayloadEvent + headersAndImplicitPayload: HeadersAndImplicitPayloadEvent + error: ErrorEvent +} + +structure HeadersEvent { + @eventHeader + booleanHeader: Boolean + + @eventHeader + byteHeader: Byte + + @eventHeader + shortHeader: Short + + @eventHeader + intHeader: Integer + + @eventHeader + longHeader: Long + + @eventHeader + blobHeader: Blob + + @eventHeader + stringHeader: String + + @eventHeader + timestampHeader: DateTime +} + +structure BlobPayloadEvent { + @eventPayload + payload: Blob +} + +structure StringPayloadEvent { + @eventPayload + payload: String +} + +structure StructurePayloadEvent { + @eventPayload + payload: PayloadStructure +} + +structure PayloadStructure { + structureMember: String +} + +structure UnionPayloadEvent { + @eventPayload + payload: PayloadUnion +} + +union PayloadUnion { + unionMember: String +} + +structure HeadersAndExplicitPayloadEvent { + @eventHeader + header: String + + @eventPayload + payload: PayloadStructure +} + +structure HeadersAndImplicitPayloadEvent { + @eventHeader + header: String + + payload: String +} + +@error("client") +structure ErrorEvent { + message: String +} + +@eventStreamTests([ + { + id: "BooleanHeaderInput" + protocol: restJson1 + events: [ + { + type: "request" + params: { + headers: { booleanHeader: true } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { string: "headers" } + booleanHeader: { boolean: true } + } + bytes: "AAAASwAAADv7Cl8VDTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQcAB2hlYWRlcnMNYm9vbGVhbkhlYWRlcgC4J9Ws" + } + ] + } + { + id: "ByteHeaderInput" + protocol: restJson1 + events: [ + { + type: "request" + params: { + headers: { byteHeader: 1 } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { string: "headers" } + byteHeader: { byte: 1 } + } + bytes: "AAAASQAAADlvxG1ZDTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQcAB2hlYWRlcnMKYnl0ZUhlYWRlcgIBKFTmjg==" + } + ] + } + { + id: "ShortHeaderInput" + protocol: restJson1 + events: [ + { + type: "request" + params: { + headers: { shortHeader: 2 } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { string: "headers" } + shortHeader: { short: 2 } + } + bytes: "AAAASwAAADv7Cl8VDTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQcAB2hlYWRlcnMLc2hvcnRIZWFkZXIDAAL1ETsK" + } + ] + } + { + id: "IntegerHeaderInput" + protocol: restJson1 + events: [ + { + type: "request" + params: { + headers: { intHeader: 3 } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { string: "headers" } + intHeader: { integer: 3 } + } + bytes: "AAAASwAAADv7Cl8VDTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQcAB2hlYWRlcnMJaW50SGVhZGVyBAAAAAPlyUrb" + } + ] + } + { + id: "LongHeaderInput" + protocol: restJson1 + events: [ + { + type: "request" + params: { + headers: { longHeader: 4294967294 } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { string: "headers" } + longHeader: { long: 4294967294 } + } + bytes: "AAAAUAAAAEAr7VEyDTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQcAB2hlYWRlcnMKbG9uZ0hlYWRlcgUAAAAA/////udnd/I=" + } + ] + } + { + id: "BlobHeaderInput" + protocol: restJson1 + events: [ + { + type: "request" + params: { + headers: { blobHeader: "Zm9v" } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { string: "headers" } + blobHeader: { blob: "Zm9v" } + } + bytes: "AAAATQAAAD2dKQ+ADTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQcAB2hlYWRlcnMKYmxvYkhlYWRlcgYAA2Zvb5sbbGM=" + } + ] + } + { + id: "StringHeaderInput" + protocol: restJson1 + events: [ + { + type: "request" + params: { + headers: { stringHeader: "foo" } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { string: "headers" } + stringHeader: { string: "foo" } + } + bytes: "AAAATwAAAD8J5z3MDTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQcAB2hlYWRlcnMMc3RyaW5nSGVhZGVyBwADZm9vxT+2MA==" + } + ] + } + { + id: "TimestampHeaderInput" + protocol: restJson1 + events: [ + { + type: "request" + params: { + headers: { timestampHeader: "2024-10-31T14:15:14Z" } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { string: "headers" } + timestampHeader: { timestamp: "2024-10-31T14:15:14Z" } + } + bytes: "AAAAVQAAAEWTZyrNDTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQcAB2hlYWRlcnMPdGltZXN0YW1wSGVhZGVyCAAAAZLi7jFQ6uV3Eg==" + } + ] + } + { + id: "MultipleHeaderInput" + protocol: restJson1 + events: [ + { + type: "request" + params: { + headers: { booleanHeader: true, stringHeader: "foo", blobHeader: "YmFy" } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { string: "headers" } + booleanHeader: { boolean: true } + stringHeader: { string: "foo" } + blobHeader: { blob: "YmFy" } + } + bytes: "AAAAbwAAAF+FlHOQDTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQcAB2hlYWRlcnMNYm9vbGVhbkhlYWRlcgAMc3RyaW5nSGVhZGVyBwADZm9vCmJsb2JIZWFkZXIGAANiYXIDXbo7" + } + ] + } + { + id: "StringPayloadInput" + protocol: restJson1 + events: [ + { + type: "request" + params: { + stringPayload: { payload: "foo" } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { string: "stringPayload" } + ":content-type": { string: "text/plain" } + } + body: "foo" + bodyMediaType: "text/plain" + bytes: "AAAAYAAAAE30fZUJDTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQcADXN0cmluZ1BheWxvYWQNOmNvbnRlbnQtdHlwZQcACnRleHQvcGxhaW5mb29G1ELr" + } + ] + } + { + id: "BlobPayloadInput" + protocol: restJson1 + events: [ + { + type: "request" + params: { + blobPayload: { payload: "bar" } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { string: "blobPayload" } + ":content-type": { string: "application/octet-stream" } + } + body: "bar" + bodyMediaType: "application/octet-stream" + bytes: "AAAAbAAAAFkrV6x1DTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQcAC2Jsb2JQYXlsb2FkDTpjb250ZW50LXR5cGUHABhhcHBsaWNhdGlvbi9vY3RldC1zdHJlYW1iYXJv5nGJ" + } + ] + } + { + id: "StructurePayloadInput" + protocol: restJson1 + events: [ + { + type: "request" + params: { + structurePayload: { + payload: { structureMember: "foo" } + } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { string: "structurePayload" } + ":content-type": { string: "application/json" } + } + body: """ + {"structureMember":"foo"}""" + bodyMediaType: "application/json" + bytes: "AAAAfwAAAFacqFy2DTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQcAEHN0cnVjdHVyZVBheWxvYWQNOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb257InN0cnVjdHVyZU1lbWJlciI6ImZvbyJ9rcIRVA==" + } + ] + } + { + id: "UnionPayloadInput" + protocol: restJson1 + events: [ + { + type: "request" + params: { + unionPayload: { + payload: { unionMember: "bar" } + } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { string: "unionPayload" } + ":content-type": { string: "application/json" } + } + body: """ + {"unionMember":"bar"}""" + bodyMediaType: "application/json" + bytes: "AAAAdwAAAFKrtdNuDTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQcADHVuaW9uUGF5bG9hZA06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbnsidW5pb25NZW1iZXIiOiJiYXIifcZDMD4=" + } + ] + } + { + id: "HeadersAndExplicitPayloadInput" + protocol: restJson1 + events: [ + { + type: "request" + params: { + headersAndExplicitPayload: { + header: "foo" + payload: { structureMember: "bar" } + } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { string: "headersAndExplicitPayload" } + ":content-type": { string: "application/json" } + header: { string: "foo" } + } + body: """ + {"structureMember":"bar"}""" + bodyMediaType: "application/json" + bytes: "AAAAlQAAAGw4wFp6DTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQcAGWhlYWRlcnNBbmRFeHBsaWNpdFBheWxvYWQNOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24GaGVhZGVyBwADZm9veyJzdHJ1Y3R1cmVNZW1iZXIiOiJiYXIifTafKXs=" + } + ] + } + { + id: "HeadersAndImplicitPayloadInput" + protocol: restJson1 + events: [ + { + type: "request" + params: { + headersAndImplicitPayload: { header: "foo", payload: "bar" } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { string: "headersAndImplicitPayload" } + ":content-type": { string: "application/json" } + header: { string: "foo" } + } + body: """ + {"payload":"bar"}""" + bodyMediaType: "application/json" + bytes: "AAAAjQAAAGxoUIY5DTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQcAGWhlYWRlcnNBbmRJbXBsaWNpdFBheWxvYWQNOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24GaGVhZGVyBwADZm9veyJwYXlsb2FkIjoiYmFyIn15lZtT" + } + ] + } + { + id: "ServerErrorInput" + protocol: restJson1 + events: [ + { + type: "request" + params: { + error: { message: "foo" } + } + headers: { + ":message-type": { string: "exception" } + ":exception-type": { string: "error" } + ":content-type": { string: "application/json" } + } + body: """ + {"message":"foo"}""" + bodyMediaType: "application/json" + bytes: "AAAAdAAAAFObEpkoDTptZXNzYWdlLXR5cGUHAAlleGNlcHRpb24POmV4Y2VwdGlvbi10eXBlBwAFZXJyb3INOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb257Im1lc3NhZ2UiOiJmb28ifTua1S8=" + } + ] + expectation: { + failure: { errorId: ErrorEvent } + } + appliesTo: "server" + } + { + id: "ClientErrorInput" + protocol: restJson1 + events: [ + { + type: "request" + params: { + error: { message: "foo" } + } + headers: { + ":message-type": { string: "exception" } + ":exception-type": { string: "error" } + ":content-type": { string: "application/json" } + } + body: """ + {"message":"foo"}""" + bodyMediaType: "application/json" + bytes: "AAAAdAAAAFObEpkoDTptZXNzYWdlLXR5cGUHAAlleGNlcHRpb24POmV4Y2VwdGlvbi10eXBlBwAFZXJyb3INOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb257Im1lc3NhZ2UiOiJmb28ifTua1S8=" + } + ] + appliesTo: "client" + } + { + id: "ServerUnexpectedErrorInput" + documentation: "Servers must be able to handle structured, but unmodeled errors." + protocol: restJson1 + events: [ + { + type: "request" + headers: { + ":message-type": { string: "error" } + ":error-code": { string: "internal-error" } + ":error-message": { string: "An unknown error occurred." } + } + bytes: "AAAAbwAAAF+FlHOQDTptZXNzYWdlLXR5cGUHAAVlcnJvcgs6ZXJyb3ItY29kZQcADmludGVybmFsLWVycm9yDjplcnJvci1tZXNzYWdlBwAaQW4gdW5rbm93biBlcnJvciBvY2N1cnJlZC4kun0t" + } + ] + expectation: { + failure: {} + } + appliesTo: "server" + } + { + id: "MissingMessageTypeInput" + documentation: "Servers must reject events that don't contain a :message-type header." + protocol: restJson1 + events: [ + { + type: "request" + params: { + headersAndExplicitPayload: { + header: "foo" + payload: { structureMember: "bar" } + } + } + headers: { + ":event-type": { string: "headersAndExplicitPayload" } + ":content-type": { string: "application/json" } + header: { string: "foo" } + } + body: """ + {"structureMember":"bar"}""" + bodyMediaType: "application/json" + bytes: "AAAAfwAAAFacqFy2CzpldmVudC10eXBlBwAZaGVhZGVyc0FuZEV4cGxpY2l0UGF5bG9hZA06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbgZoZWFkZXIHAANmb297InN0cnVjdHVyZU1lbWJlciI6ImJhciJ98LexJg==" + } + ] + expectation: { + failure: {} + } + appliesTo: "server" + } + { + id: "MalformedMessageTypeInput" + documentation: "Servers must reject events that contain a malformed :message-type header." + protocol: restJson1 + events: [ + { + type: "request" + params: { + headersAndExplicitPayload: { + header: "foo" + payload: { structureMember: "bar" } + } + } + headers: { + ":message-type": { blob: "ZXZlbnQ=" } + ":content-type": { string: "application/json" } + header: { string: "foo" } + } + body: """ + {"structureMember":"bar"}""" + bodyMediaType: "application/json" + bytes: "AAAAbQAAAER1MekcDTptZXNzYWdlLXR5cGUGAAVldmVudA06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbgZoZWFkZXIHAANmb297InN0cnVjdHVyZU1lbWJlciI6ImJhciJ95dXDSw==" + } + ] + expectation: { + failure: {} + } + appliesTo: "server" + } + { + id: "MissingEventTypeInput" + documentation: "Servers must reject message events that don't contain an :event-type header." + protocol: restJson1 + events: [ + { + type: "request" + params: { + headersAndExplicitPayload: { + header: "foo" + payload: { structureMember: "bar" } + } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { string: "headersAndExplicitPayload" } + ":content-type": { string: "application/json" } + header: { string: "foo" } + } + body: """ + {"structureMember":"bar"}""" + bodyMediaType: "application/json" + bytes: "AAAAlQAAAGw4wFp6DTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQcAGWhlYWRlcnNBbmRFeHBsaWNpdFBheWxvYWQNOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24GaGVhZGVyBwADZm9veyJzdHJ1Y3R1cmVNZW1iZXIiOiJiYXIifTafKXs=" + } + ] + expectation: { + failure: {} + } + appliesTo: "server" + } + { + id: "MalformedEventTypeInput" + documentation: "Servers must reject message events that contain a malformed :event-type header." + protocol: restJson1 + events: [ + { + type: "request" + params: { + headersAndExplicitPayload: { + header: "foo" + payload: { structureMember: "bar" } + } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { blob: "aGVhZGVyc0FuZEV4cGxpY2l0UGF5bG9hZA==" } + ":content-type": { string: "application/json" } + header: { string: "foo" } + } + body: """ + {"structureMember":"bar"}""" + bodyMediaType: "application/json" + bytes: "AAAAlQAAAGw4wFp6DTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQYAGWhlYWRlcnNBbmRFeHBsaWNpdFBheWxvYWQNOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24GaGVhZGVyBwADZm9veyJzdHJ1Y3R1cmVNZW1iZXIiOiJiYXIifcP6KLk=" + } + ] + expectation: { + failure: {} + } + appliesTo: "server" + } +]) +@http(method: "POST", uri: "/InputStream") +operation InputStream { + input := { + @httpPayload + stream: EventStream + } +} + +@eventStreamTests([ + { + id: "BooleanHeaderOutput" + protocol: restJson1 + events: [ + { + type: "response" + params: { + headers: { booleanHeader: true } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { string: "headers" } + booleanHeader: { boolean: true } + } + bytes: "AAAASwAAADv7Cl8VDTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQcAB2hlYWRlcnMNYm9vbGVhbkhlYWRlcgC4J9Ws" + } + ] + } + { + id: "ByteHeaderOutput" + protocol: restJson1 + events: [ + { + type: "response" + params: { + headers: { byteHeader: 1 } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { string: "headers" } + byteHeader: { byte: 1 } + } + bytes: "AAAASQAAADlvxG1ZDTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQcAB2hlYWRlcnMKYnl0ZUhlYWRlcgIBKFTmjg==" + } + ] + } + { + id: "ShortHeaderOutput" + protocol: restJson1 + events: [ + { + type: "response" + params: { + headers: { shortHeader: 2 } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { string: "headers" } + shortHeader: { short: 2 } + } + bytes: "AAAASwAAADv7Cl8VDTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQcAB2hlYWRlcnMLc2hvcnRIZWFkZXIDAAL1ETsK" + } + ] + } + { + id: "IntegerHeaderOutput" + protocol: restJson1 + events: [ + { + type: "response" + params: { + headers: { intHeader: 3 } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { string: "headers" } + intHeader: { integer: 3 } + } + bytes: "AAAASwAAADv7Cl8VDTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQcAB2hlYWRlcnMJaW50SGVhZGVyBAAAAAPlyUrb" + } + ] + } + { + id: "LongHeaderOutput" + protocol: restJson1 + events: [ + { + type: "response" + params: { + headers: { longHeader: 4294967294 } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { string: "headers" } + longHeader: { long: 4294967294 } + } + bytes: "AAAAUAAAAEAr7VEyDTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQcAB2hlYWRlcnMKbG9uZ0hlYWRlcgUAAAAA/////udnd/I=" + } + ] + } + { + id: "BlobHeaderOutput" + protocol: restJson1 + events: [ + { + type: "response" + params: { + headers: { blobHeader: "Zm9v" } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { string: "headers" } + blobHeader: { blob: "Zm9v" } + } + bytes: "AAAATQAAAD2dKQ+ADTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQcAB2hlYWRlcnMKYmxvYkhlYWRlcgYAA2Zvb5sbbGM=" + } + ] + } + { + id: "StringHeaderOutput" + protocol: restJson1 + events: [ + { + type: "response" + params: { + headers: { stringHeader: "foo" } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { string: "headers" } + stringHeader: { string: "foo" } + } + bytes: "AAAATwAAAD8J5z3MDTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQcAB2hlYWRlcnMMc3RyaW5nSGVhZGVyBwADZm9vxT+2MA==" + } + ] + } + { + id: "TimestampHeaderOutput" + protocol: restJson1 + events: [ + { + type: "response" + params: { + headers: { timestampHeader: "2024-10-31T14:15:14Z" } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { string: "headers" } + timestampHeader: { timestamp: "2024-10-31T14:15:14Z" } + } + bytes: "AAAAVQAAAEWTZyrNDTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQcAB2hlYWRlcnMPdGltZXN0YW1wSGVhZGVyCAAAAZLi7jFQ6uV3Eg==" + } + ] + } + { + id: "MultipleHeaderOutput" + protocol: restJson1 + events: [ + { + type: "response" + params: { + headers: { booleanHeader: true, stringHeader: "foo", blobHeader: "YmFy" } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { string: "headers" } + booleanHeader: { boolean: true } + stringHeader: { string: "foo" } + blobHeader: { blob: "YmFy" } + } + bytes: "AAAAbwAAAF+FlHOQDTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQcAB2hlYWRlcnMNYm9vbGVhbkhlYWRlcgAMc3RyaW5nSGVhZGVyBwADZm9vCmJsb2JIZWFkZXIGAANiYXIDXbo7" + } + ] + } + { + id: "StringPayloadOutput" + protocol: restJson1 + events: [ + { + type: "response" + params: { + stringPayload: { payload: "foo" } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { string: "stringPayload" } + ":content-type": { string: "text/plain" } + } + body: "foo" + bodyMediaType: "text/plain" + bytes: "AAAAYAAAAE30fZUJDTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQcADXN0cmluZ1BheWxvYWQNOmNvbnRlbnQtdHlwZQcACnRleHQvcGxhaW5mb29G1ELr" + } + ] + } + { + id: "BlobPayloadOutput" + protocol: restJson1 + events: [ + { + type: "response" + params: { + blobPayload: { payload: "bar" } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { string: "blobPayload" } + ":content-type": { string: "application/octet-stream" } + } + body: "bar" + bodyMediaType: "application/octet-stream" + bytes: "AAAAbAAAAFkrV6x1DTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQcAC2Jsb2JQYXlsb2FkDTpjb250ZW50LXR5cGUHABhhcHBsaWNhdGlvbi9vY3RldC1zdHJlYW1iYXJv5nGJ" + } + ] + } + { + id: "StructurePayloadOutput" + protocol: restJson1 + events: [ + { + type: "response" + params: { + structurePayload: { + payload: { structureMember: "foo" } + } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { string: "structurePayload" } + ":content-type": { string: "application/json" } + } + body: """ + {"structureMember":"foo"}""" + bodyMediaType: "application/json" + bytes: "AAAAfwAAAFacqFy2DTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQcAEHN0cnVjdHVyZVBheWxvYWQNOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb257InN0cnVjdHVyZU1lbWJlciI6ImZvbyJ9rcIRVA==" + } + ] + } + { + id: "UnionPayloadOutput" + protocol: restJson1 + events: [ + { + type: "response" + params: { + unionPayload: { + payload: { unionMember: "bar" } + } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { string: "unionPayload" } + ":content-type": { string: "application/json" } + } + body: """ + {"unionMember":"bar"}""" + bodyMediaType: "application/json" + bytes: "AAAAdwAAAFKrtdNuDTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQcADHVuaW9uUGF5bG9hZA06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbnsidW5pb25NZW1iZXIiOiJiYXIifcZDMD4=" + } + ] + } + { + id: "HeadersAndExplicitPayloadOutput" + protocol: restJson1 + events: [ + { + type: "response" + params: { + headersAndExplicitPayload: { + header: "foo" + payload: { structureMember: "bar" } + } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { string: "headersAndExplicitPayload" } + ":content-type": { string: "application/json" } + header: { string: "foo" } + } + body: """ + {"structureMember":"bar"}""" + bodyMediaType: "application/json" + bytes: "AAAAlQAAAGw4wFp6DTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQcAGWhlYWRlcnNBbmRFeHBsaWNpdFBheWxvYWQNOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24GaGVhZGVyBwADZm9veyJzdHJ1Y3R1cmVNZW1iZXIiOiJiYXIifTafKXs=" + } + ] + } + { + id: "HeadersAndImplicitPayloadOutput" + protocol: restJson1 + events: [ + { + type: "response" + params: { + headersAndImplicitPayload: { header: "foo", payload: "bar" } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { string: "headersAndImplicitPayload" } + ":content-type": { string: "application/json" } + header: { string: "foo" } + } + body: """ + {"payload":"bar"}""" + bodyMediaType: "application/json" + bytes: "AAAAjQAAAGxoUIY5DTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQcAGWhlYWRlcnNBbmRJbXBsaWNpdFBheWxvYWQNOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24GaGVhZGVyBwADZm9veyJwYXlsb2FkIjoiYmFyIn15lZtT" + } + ] + } + { + id: "ServerErrorOutput" + protocol: restJson1 + events: [ + { + type: "response" + params: { + error: { message: "foo" } + } + headers: { + ":message-type": { string: "exception" } + ":exception-type": { string: "error" } + ":content-type": { string: "application/json" } + } + body: """ + {"message":"foo"}""" + bodyMediaType: "application/json" + bytes: "AAAAdAAAAFObEpkoDTptZXNzYWdlLXR5cGUHAAlleGNlcHRpb24POmV4Y2VwdGlvbi10eXBlBwAFZXJyb3INOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb257Im1lc3NhZ2UiOiJmb28ifTua1S8=" + } + ] + appliesTo: "server" + } + { + id: "ClientErrorOutput" + protocol: restJson1 + events: [ + { + type: "response" + params: { + error: { message: "foo" } + } + headers: { + ":message-type": { string: "exception" } + ":exception-type": { string: "error" } + ":content-type": { string: "application/json" } + } + body: """ + {"message":"foo"}""" + bodyMediaType: "application/json" + bytes: "AAAAdAAAAFObEpkoDTptZXNzYWdlLXR5cGUHAAlleGNlcHRpb24POmV4Y2VwdGlvbi10eXBlBwAFZXJyb3INOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb257Im1lc3NhZ2UiOiJmb28ifTua1S8=" + } + ] + expectation: { + failure: { errorId: ErrorEvent } + } + appliesTo: "client" + } + { + id: "ClientUnexpectedErrorOutput" + documentation: "Clients must be able to handle structured, but unmodeled errors." + protocol: restJson1 + events: [ + { + type: "request" + headers: { + ":message-type": { string: "error" } + ":error-code": { string: "internal-error" } + ":error-message": { string: "An unknown error occurred." } + } + bytes: "AAAAbwAAAF+FlHOQDTptZXNzYWdlLXR5cGUHAAVlcnJvcgs6ZXJyb3ItY29kZQcADmludGVybmFsLWVycm9yDjplcnJvci1tZXNzYWdlBwAaQW4gdW5rbm93biBlcnJvciBvY2N1cnJlZC4kun0t" + } + ] + expectation: { + failure: {} + } + appliesTo: "client" + } + { + id: "MissingMessageTypeOutput" + documentation: "Clients must reject events that don't contain a :message-type header." + protocol: restJson1 + events: [ + { + type: "response" + params: { + headersAndExplicitPayload: { + header: "foo" + payload: { structureMember: "bar" } + } + } + headers: { + ":event-type": { string: "headersAndExplicitPayload" } + ":content-type": { string: "application/json" } + header: { string: "foo" } + } + body: """ + {"structureMember":"bar"}""" + bodyMediaType: "application/json" + bytes: "AAAAfwAAAFacqFy2CzpldmVudC10eXBlBwAZaGVhZGVyc0FuZEV4cGxpY2l0UGF5bG9hZA06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbgZoZWFkZXIHAANmb297InN0cnVjdHVyZU1lbWJlciI6ImJhciJ98LexJg==" + } + ] + expectation: { + failure: {} + } + appliesTo: "client" + } + { + id: "MalformedMessageTypeOutput" + documentation: "Client must reject events that contain a malformed :message-type header." + protocol: restJson1 + events: [ + { + type: "response" + params: { + headersAndExplicitPayload: { + header: "foo" + payload: { structureMember: "bar" } + } + } + headers: { + ":message-type": { blob: "ZXZlbnQ=" } + ":event-type": { string: "headersAndExplicitPayload" } + ":content-type": { string: "application/json" } + header: { string: "foo" } + } + body: """ + {"structureMember":"bar"}""" + bodyMediaType: "application/json" + bytes: "AAAAlQAAAGw4wFp6DTptZXNzYWdlLXR5cGUGAAVldmVudAs6ZXZlbnQtdHlwZQcAGWhlYWRlcnNBbmRFeHBsaWNpdFBheWxvYWQNOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24GaGVhZGVyBwADZm9veyJzdHJ1Y3R1cmVNZW1iZXIiOiJiYXIifVwdfzU=" + } + ] + expectation: { + failure: {} + } + appliesTo: "client" + } + { + id: "MissingEventTypeOutput" + documentation: "Clients must reject message events that don't contain an :event-type header." + protocol: restJson1 + events: [ + { + type: "response" + params: { + headersAndExplicitPayload: { + header: "foo" + payload: { structureMember: "bar" } + } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { string: "headersAndExplicitPayload" } + ":content-type": { string: "application/json" } + header: { string: "foo" } + } + body: """ + {"structureMember":"bar"}""" + bodyMediaType: "application/json" + bytes: "AAAAlQAAAGw4wFp6DTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQcAGWhlYWRlcnNBbmRFeHBsaWNpdFBheWxvYWQNOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24GaGVhZGVyBwADZm9veyJzdHJ1Y3R1cmVNZW1iZXIiOiJiYXIifTafKXs=" + } + ] + expectation: { + failure: {} + } + appliesTo: "client" + } + { + id: "MalformedEventTypeOutput" + documentation: "Clients must reject message events that contain a malformed :event-type header." + protocol: restJson1 + events: [ + { + type: "response" + params: { + headersAndExplicitPayload: { + header: "foo" + payload: { structureMember: "bar" } + } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { blob: "aGVhZGVyc0FuZEV4cGxpY2l0UGF5bG9hZA==" } + ":content-type": { string: "application/json" } + header: { string: "foo" } + } + body: """ + {"structureMember":"bar"}""" + bodyMediaType: "application/json" + bytes: "AAAAlQAAAGw4wFp6DTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQYAGWhlYWRlcnNBbmRFeHBsaWNpdFBheWxvYWQNOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24GaGVhZGVyBwADZm9veyJzdHJ1Y3R1cmVNZW1iZXIiOiJiYXIifcP6KLk=" + } + ] + expectation: { + failure: {} + } + appliesTo: "client" + } + { + id: "ModeledProtocolError" + protocol: restJson1 + initialResponse: { + code: 500 + headers: { "Content-Type": "application/json", "X-Amzn-Errortype": "ServiceUnavailableError" } + body: """ + {"message": "foo"}""" + bodyMediaType: "application/json" + } + initialResponseShape: InitialHttpResponse + expectation: { + failure: { errorId: ServiceUnavailableError } + } + appliesTo: "client" + } + { + id: "UnmodeledProtocolError" + protocol: restJson1 + initialResponse: { + code: 500 + headers: { "Content-Type": "text/plain" } + body: "service unavailable" + bodyMediaType: "text/plain" + } + initialResponseShape: InitialHttpResponse + expectation: { + failure: {} + } + appliesTo: "client" + } +]) +@http(method: "POST", uri: "/OutputStream") +operation OutputStream { + output := { + @httpPayload + stream: EventStream + } + + errors: [ + ServiceUnavailableError + ] +} + +@error("server") +@httpError(500) +structure ServiceUnavailableError { + message: String +} + +@eventStreamTests([ + { + id: "DuplexBooleanHeaderInput" + protocol: restJson1 + events: [ + { + type: "request" + params: { + headers: { booleanHeader: true } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { string: "headers" } + booleanHeader: { boolean: true } + } + bytes: "AAAASwAAADv7Cl8VDTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQcAB2hlYWRlcnMNYm9vbGVhbkhlYWRlcgC4J9Ws" + } + ] + } + { + id: "DuplexByteHeaderInput" + protocol: restJson1 + events: [ + { + type: "request" + params: { + headers: { byteHeader: 1 } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { string: "headers" } + byteHeader: { byte: 1 } + } + bytes: "AAAASQAAADlvxG1ZDTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQcAB2hlYWRlcnMKYnl0ZUhlYWRlcgIBKFTmjg==" + } + ] + } + { + id: "DuplexShortHeaderInput" + protocol: restJson1 + events: [ + { + type: "request" + params: { + headers: { shortHeader: 2 } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { string: "headers" } + shortHeader: { short: 2 } + } + bytes: "AAAASwAAADv7Cl8VDTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQcAB2hlYWRlcnMLc2hvcnRIZWFkZXIDAAL1ETsK" + } + ] + } + { + id: "DuplexIntegerHeaderInput" + protocol: restJson1 + events: [ + { + type: "request" + params: { + headers: { intHeader: 3 } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { string: "headers" } + intHeader: { integer: 3 } + } + bytes: "AAAASwAAADv7Cl8VDTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQcAB2hlYWRlcnMJaW50SGVhZGVyBAAAAAPlyUrb" + } + ] + } + { + id: "DuplexLongHeaderInput" + protocol: restJson1 + events: [ + { + type: "request" + params: { + headers: { longHeader: 4294967294 } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { string: "headers" } + longHeader: { long: 4294967294 } + } + bytes: "AAAAUAAAAEAr7VEyDTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQcAB2hlYWRlcnMKbG9uZ0hlYWRlcgUAAAAA/////udnd/I=" + } + ] + } + { + id: "DuplexBlobHeaderInput" + protocol: restJson1 + events: [ + { + type: "request" + params: { + headers: { blobHeader: "Zm9v" } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { string: "headers" } + blobHeader: { blob: "Zm9v" } + } + bytes: "AAAATQAAAD2dKQ+ADTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQcAB2hlYWRlcnMKYmxvYkhlYWRlcgYAA2Zvb5sbbGM=" + } + ] + } + { + id: "DuplexStringHeaderInput" + protocol: restJson1 + events: [ + { + type: "request" + params: { + headers: { stringHeader: "foo" } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { string: "headers" } + stringHeader: { string: "foo" } + } + bytes: "AAAATwAAAD8J5z3MDTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQcAB2hlYWRlcnMMc3RyaW5nSGVhZGVyBwADZm9vxT+2MA==" + } + ] + } + { + id: "DuplexTimestampHeaderInput" + protocol: restJson1 + events: [ + { + type: "request" + params: { + headers: { timestampHeader: "2024-10-31T14:15:14Z" } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { string: "headers" } + timestampHeader: { timestamp: "2024-10-31T14:15:14Z" } + } + bytes: "AAAAVQAAAEWTZyrNDTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQcAB2hlYWRlcnMPdGltZXN0YW1wSGVhZGVyCAAAAZLi7jFQ6uV3Eg==" + } + ] + } + { + id: "DuplexMultipleHeaderInput" + protocol: restJson1 + events: [ + { + type: "request" + params: { + headers: { booleanHeader: true, stringHeader: "foo", blobHeader: "YmFy" } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { string: "headers" } + booleanHeader: { boolean: true } + stringHeader: { string: "foo" } + blobHeader: { blob: "YmFy" } + } + bytes: "AAAAbwAAAF+FlHOQDTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQcAB2hlYWRlcnMNYm9vbGVhbkhlYWRlcgAMc3RyaW5nSGVhZGVyBwADZm9vCmJsb2JIZWFkZXIGAANiYXIDXbo7" + } + ] + } + { + id: "DuplexStringPayloadInput" + protocol: restJson1 + events: [ + { + type: "request" + params: { + stringPayload: { payload: "foo" } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { string: "stringPayload" } + ":content-type": { string: "text/plain" } + } + body: "foo" + bodyMediaType: "text/plain" + bytes: "AAAAYAAAAE30fZUJDTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQcADXN0cmluZ1BheWxvYWQNOmNvbnRlbnQtdHlwZQcACnRleHQvcGxhaW5mb29G1ELr" + } + ] + } + { + id: "DuplexBlobPayloadInput" + protocol: restJson1 + events: [ + { + type: "request" + params: { + blobPayload: { payload: "bar" } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { string: "blobPayload" } + ":content-type": { string: "application/octet-stream" } + } + body: "bar" + bodyMediaType: "application/octet-stream" + bytes: "AAAAbAAAAFkrV6x1DTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQcAC2Jsb2JQYXlsb2FkDTpjb250ZW50LXR5cGUHABhhcHBsaWNhdGlvbi9vY3RldC1zdHJlYW1iYXJv5nGJ" + } + ] + } + { + id: "DuplexStructurePayloadInput" + protocol: restJson1 + events: [ + { + type: "request" + params: { + structurePayload: { + payload: { structureMember: "foo" } + } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { string: "structurePayload" } + ":content-type": { string: "application/json" } + } + body: """ + {"structureMember":"foo"}""" + bodyMediaType: "application/json" + bytes: "AAAAfwAAAFacqFy2DTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQcAEHN0cnVjdHVyZVBheWxvYWQNOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb257InN0cnVjdHVyZU1lbWJlciI6ImZvbyJ9rcIRVA==" + } + ] + } + { + id: "DuplexUnionPayloadInput" + protocol: restJson1 + events: [ + { + type: "request" + params: { + unionPayload: { + payload: { unionMember: "bar" } + } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { string: "unionPayload" } + ":content-type": { string: "application/json" } + } + body: """ + {"unionMember":"bar"}""" + bodyMediaType: "application/json" + bytes: "AAAAdwAAAFKrtdNuDTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQcADHVuaW9uUGF5bG9hZA06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbnsidW5pb25NZW1iZXIiOiJiYXIifcZDMD4=" + } + ] + } + { + id: "DuplexHeadersAndExplicitPayloadInput" + protocol: restJson1 + events: [ + { + type: "request" + params: { + headersAndExplicitPayload: { + header: "foo" + payload: { structureMember: "bar" } + } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { string: "headersAndExplicitPayload" } + ":content-type": { string: "application/json" } + header: { string: "foo" } + } + body: """ + {"structureMember":"bar"}""" + bodyMediaType: "application/json" + bytes: "AAAAlQAAAGw4wFp6DTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQcAGWhlYWRlcnNBbmRFeHBsaWNpdFBheWxvYWQNOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24GaGVhZGVyBwADZm9veyJzdHJ1Y3R1cmVNZW1iZXIiOiJiYXIifTafKXs=" + } + ] + } + { + id: "DuplexHeadersAndImplicitPayloadInput" + protocol: restJson1 + events: [ + { + type: "request" + params: { + headersAndImplicitPayload: { header: "foo", payload: "bar" } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { string: "headersAndImplicitPayload" } + ":content-type": { string: "application/json" } + header: { string: "foo" } + } + body: """ + {"payload":"bar"}""" + bodyMediaType: "application/json" + bytes: "AAAAjQAAAGxoUIY5DTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQcAGWhlYWRlcnNBbmRJbXBsaWNpdFBheWxvYWQNOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24GaGVhZGVyBwADZm9veyJwYXlsb2FkIjoiYmFyIn15lZtT" + } + ] + } + { + id: "DuplexServerErrorInput" + protocol: restJson1 + events: [ + { + type: "request" + params: { + error: { message: "foo" } + } + headers: { + ":message-type": { string: "exception" } + ":exception-type": { string: "error" } + ":content-type": { string: "application/json" } + } + body: """ + {"message":"foo"}""" + bodyMediaType: "application/json" + bytes: "AAAAdAAAAFObEpkoDTptZXNzYWdlLXR5cGUHAAlleGNlcHRpb24POmV4Y2VwdGlvbi10eXBlBwAFZXJyb3INOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb257Im1lc3NhZ2UiOiJmb28ifTua1S8=" + } + ] + expectation: { + failure: { errorId: ErrorEvent } + } + appliesTo: "server" + } + { + id: "DuplexClientErrorInput" + protocol: restJson1 + events: [ + { + type: "request" + params: { + error: { message: "foo" } + } + headers: { + ":message-type": { string: "exception" } + ":exception-type": { string: "error" } + ":content-type": { string: "application/json" } + } + body: """ + {"message":"foo"}""" + bodyMediaType: "application/json" + bytes: "AAAAdAAAAFObEpkoDTptZXNzYWdlLXR5cGUHAAlleGNlcHRpb24POmV4Y2VwdGlvbi10eXBlBwAFZXJyb3INOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb257Im1lc3NhZ2UiOiJmb28ifTua1S8=" + } + ] + appliesTo: "client" + } + { + id: "DuplexServerUnexpectedErrorInput" + documentation: "Servers must be able to handle structured, but unmodeled errors." + protocol: restJson1 + events: [ + { + type: "request" + headers: { + ":message-type": { string: "error" } + ":error-code": { string: "internal-error" } + ":error-message": { string: "An unknown error occurred." } + } + bytes: "AAAAbwAAAF+FlHOQDTptZXNzYWdlLXR5cGUHAAVlcnJvcgs6ZXJyb3ItY29kZQcADmludGVybmFsLWVycm9yDjplcnJvci1tZXNzYWdlBwAaQW4gdW5rbm93biBlcnJvciBvY2N1cnJlZC4kun0t" + } + ] + expectation: { + failure: {} + } + appliesTo: "server" + } + { + id: "DuplexMissingMessageTypeInput" + documentation: "Servers must reject events that don't contain a :message-type header." + protocol: restJson1 + events: [ + { + type: "request" + params: { + headersAndExplicitPayload: { + header: "foo" + payload: { structureMember: "bar" } + } + } + headers: { + ":event-type": { string: "headersAndExplicitPayload" } + ":content-type": { string: "application/json" } + header: { string: "foo" } + } + body: """ + {"structureMember":"bar"}""" + bodyMediaType: "application/json" + bytes: "AAAAfwAAAFacqFy2CzpldmVudC10eXBlBwAZaGVhZGVyc0FuZEV4cGxpY2l0UGF5bG9hZA06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbgZoZWFkZXIHAANmb297InN0cnVjdHVyZU1lbWJlciI6ImJhciJ98LexJg==" + } + ] + expectation: { + failure: {} + } + appliesTo: "server" + } + { + id: "DuplexMalformedMessageTypeInput" + documentation: "Servers must reject events that contain a malformed :message-type header." + protocol: restJson1 + events: [ + { + type: "request" + params: { + headersAndExplicitPayload: { + header: "foo" + payload: { structureMember: "bar" } + } + } + headers: { + ":message-type": { blob: "ZXZlbnQ=" } + ":content-type": { string: "application/json" } + header: { string: "foo" } + } + body: """ + {"structureMember":"bar"}""" + bodyMediaType: "application/json" + bytes: "AAAAbQAAAER1MekcDTptZXNzYWdlLXR5cGUGAAVldmVudA06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbgZoZWFkZXIHAANmb297InN0cnVjdHVyZU1lbWJlciI6ImJhciJ95dXDSw==" + } + ] + expectation: { + failure: {} + } + appliesTo: "server" + } + { + id: "DuplexMissingEventTypeInput" + documentation: "Servers must reject message events that don't contain an :event-type header." + protocol: restJson1 + events: [ + { + type: "request" + params: { + headersAndExplicitPayload: { + header: "foo" + payload: { structureMember: "bar" } + } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { string: "headersAndExplicitPayload" } + ":content-type": { string: "application/json" } + header: { string: "foo" } + } + body: """ + {"structureMember":"bar"}""" + bodyMediaType: "application/json" + bytes: "AAAAlQAAAGw4wFp6DTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQcAGWhlYWRlcnNBbmRFeHBsaWNpdFBheWxvYWQNOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24GaGVhZGVyBwADZm9veyJzdHJ1Y3R1cmVNZW1iZXIiOiJiYXIifTafKXs=" + } + ] + expectation: { + failure: {} + } + appliesTo: "server" + } + { + id: "DuplexMalformedEventTypeInput" + documentation: "Servers must reject message events that contain a malformed :event-type header." + protocol: restJson1 + events: [ + { + type: "request" + params: { + headersAndExplicitPayload: { + header: "foo" + payload: { structureMember: "bar" } + } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { blob: "aGVhZGVyc0FuZEV4cGxpY2l0UGF5bG9hZA==" } + ":content-type": { string: "application/json" } + header: { string: "foo" } + } + body: """ + {"structureMember":"bar"}""" + bodyMediaType: "application/json" + bytes: "AAAAlQAAAGw4wFp6DTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQYAGWhlYWRlcnNBbmRFeHBsaWNpdFBheWxvYWQNOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24GaGVhZGVyBwADZm9veyJzdHJ1Y3R1cmVNZW1iZXIiOiJiYXIifcP6KLk=" + } + ] + expectation: { + failure: {} + } + appliesTo: "server" + } + { + id: "DuplexBooleanHeaderOutput" + protocol: restJson1 + events: [ + { + type: "response" + params: { + headers: { booleanHeader: true } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { string: "headers" } + booleanHeader: { boolean: true } + } + bytes: "AAAASwAAADv7Cl8VDTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQcAB2hlYWRlcnMNYm9vbGVhbkhlYWRlcgC4J9Ws" + } + ] + } + { + id: "DuplexByteHeaderOutput" + protocol: restJson1 + events: [ + { + type: "response" + params: { + headers: { byteHeader: 1 } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { string: "headers" } + byteHeader: { byte: 1 } + } + bytes: "AAAASQAAADlvxG1ZDTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQcAB2hlYWRlcnMKYnl0ZUhlYWRlcgIBKFTmjg==" + } + ] + } + { + id: "DuplexShortHeaderOutput" + protocol: restJson1 + events: [ + { + type: "response" + params: { + headers: { shortHeader: 2 } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { string: "headers" } + shortHeader: { short: 2 } + } + bytes: "AAAASwAAADv7Cl8VDTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQcAB2hlYWRlcnMLc2hvcnRIZWFkZXIDAAL1ETsK" + } + ] + } + { + id: "DuplexIntegerHeaderOutput" + protocol: restJson1 + events: [ + { + type: "response" + params: { + headers: { intHeader: 3 } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { string: "headers" } + intHeader: { integer: 3 } + } + bytes: "AAAASwAAADv7Cl8VDTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQcAB2hlYWRlcnMJaW50SGVhZGVyBAAAAAPlyUrb" + } + ] + } + { + id: "DuplexLongHeaderOutput" + protocol: restJson1 + events: [ + { + type: "response" + params: { + headers: { longHeader: 4294967294 } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { string: "headers" } + longHeader: { long: 4294967294 } + } + bytes: "AAAAUAAAAEAr7VEyDTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQcAB2hlYWRlcnMKbG9uZ0hlYWRlcgUAAAAA/////udnd/I=" + } + ] + } + { + id: "DuplexBlobHeaderOutput" + protocol: restJson1 + events: [ + { + type: "response" + params: { + headers: { blobHeader: "Zm9v" } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { string: "headers" } + blobHeader: { blob: "Zm9v" } + } + bytes: "AAAATQAAAD2dKQ+ADTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQcAB2hlYWRlcnMKYmxvYkhlYWRlcgYAA2Zvb5sbbGM=" + } + ] + } + { + id: "DuplexStringHeaderOutput" + protocol: restJson1 + events: [ + { + type: "response" + params: { + headers: { stringHeader: "foo" } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { string: "headers" } + stringHeader: { string: "foo" } + } + bytes: "AAAATwAAAD8J5z3MDTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQcAB2hlYWRlcnMMc3RyaW5nSGVhZGVyBwADZm9vxT+2MA==" + } + ] + } + { + id: "DuplexTimestampHeaderOutput" + protocol: restJson1 + events: [ + { + type: "response" + params: { + headers: { timestampHeader: "2024-10-31T14:15:14Z" } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { string: "headers" } + timestampHeader: { timestamp: "2024-10-31T14:15:14Z" } + } + bytes: "AAAAVQAAAEWTZyrNDTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQcAB2hlYWRlcnMPdGltZXN0YW1wSGVhZGVyCAAAAZLi7jFQ6uV3Eg==" + } + ] + } + { + id: "DuplexMultipleHeaderOutput" + protocol: restJson1 + events: [ + { + type: "response" + params: { + headers: { booleanHeader: true, stringHeader: "foo", blobHeader: "YmFy" } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { string: "headers" } + booleanHeader: { boolean: true } + stringHeader: { string: "foo" } + blobHeader: { blob: "YmFy" } + } + bytes: "AAAAbwAAAF+FlHOQDTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQcAB2hlYWRlcnMNYm9vbGVhbkhlYWRlcgAMc3RyaW5nSGVhZGVyBwADZm9vCmJsb2JIZWFkZXIGAANiYXIDXbo7" + } + ] + } + { + id: "DuplexStringPayloadOutput" + protocol: restJson1 + events: [ + { + type: "response" + params: { + stringPayload: { payload: "foo" } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { string: "stringPayload" } + ":content-type": { string: "text/plain" } + } + body: "foo" + bodyMediaType: "text/plain" + bytes: "AAAAYAAAAE30fZUJDTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQcADXN0cmluZ1BheWxvYWQNOmNvbnRlbnQtdHlwZQcACnRleHQvcGxhaW5mb29G1ELr" + } + ] + } + { + id: "DuplexBlobPayloadOutput" + protocol: restJson1 + events: [ + { + type: "response" + params: { + blobPayload: { payload: "bar" } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { string: "blobPayload" } + ":content-type": { string: "application/octet-stream" } + } + body: "bar" + bodyMediaType: "application/octet-stream" + bytes: "AAAAbAAAAFkrV6x1DTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQcAC2Jsb2JQYXlsb2FkDTpjb250ZW50LXR5cGUHABhhcHBsaWNhdGlvbi9vY3RldC1zdHJlYW1iYXJv5nGJ" + } + ] + } + { + id: "DuplexStructurePayloadOutput" + protocol: restJson1 + events: [ + { + type: "response" + params: { + structurePayload: { + payload: { structureMember: "foo" } + } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { string: "structurePayload" } + ":content-type": { string: "application/json" } + } + body: """ + {"structureMember":"foo"}""" + bodyMediaType: "application/json" + bytes: "AAAAfwAAAFacqFy2DTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQcAEHN0cnVjdHVyZVBheWxvYWQNOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb257InN0cnVjdHVyZU1lbWJlciI6ImZvbyJ9rcIRVA==" + } + ] + } + { + id: "DuplexUnionPayloadOutput" + protocol: restJson1 + events: [ + { + type: "response" + params: { + unionPayload: { + payload: { unionMember: "bar" } + } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { string: "unionPayload" } + ":content-type": { string: "application/json" } + } + body: """ + {"unionMember":"bar"}""" + bodyMediaType: "application/json" + bytes: "AAAAdwAAAFKrtdNuDTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQcADHVuaW9uUGF5bG9hZA06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbnsidW5pb25NZW1iZXIiOiJiYXIifcZDMD4=" + } + ] + } + { + id: "DuplexHeadersAndExplicitPayloadOutput" + protocol: restJson1 + events: [ + { + type: "response" + params: { + headersAndExplicitPayload: { + header: "foo" + payload: { structureMember: "bar" } + } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { string: "headersAndExplicitPayload" } + ":content-type": { string: "application/json" } + header: { string: "foo" } + } + body: """ + {"structureMember":"bar"}""" + bodyMediaType: "application/json" + bytes: "AAAAlQAAAGw4wFp6DTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQcAGWhlYWRlcnNBbmRFeHBsaWNpdFBheWxvYWQNOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24GaGVhZGVyBwADZm9veyJzdHJ1Y3R1cmVNZW1iZXIiOiJiYXIifTafKXs=" + } + ] + } + { + id: "DuplexHeadersAndImplicitPayloadOutput" + protocol: restJson1 + events: [ + { + type: "response" + params: { + headersAndImplicitPayload: { header: "foo", payload: "bar" } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { string: "headersAndImplicitPayload" } + ":content-type": { string: "application/json" } + header: { string: "foo" } + } + body: """ + {"payload":"bar"}""" + bodyMediaType: "application/json" + bytes: "AAAAjQAAAGxoUIY5DTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQcAGWhlYWRlcnNBbmRJbXBsaWNpdFBheWxvYWQNOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24GaGVhZGVyBwADZm9veyJwYXlsb2FkIjoiYmFyIn15lZtT" + } + ] + } + { + id: "DuplexServerErrorOutput" + protocol: restJson1 + events: [ + { + type: "response" + params: { + error: { message: "foo" } + } + headers: { + ":message-type": { string: "exception" } + ":exception-type": { string: "error" } + ":content-type": { string: "application/json" } + } + body: """ + {"message":"foo"}""" + bodyMediaType: "application/json" + bytes: "AAAAdAAAAFObEpkoDTptZXNzYWdlLXR5cGUHAAlleGNlcHRpb24POmV4Y2VwdGlvbi10eXBlBwAFZXJyb3INOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb257Im1lc3NhZ2UiOiJmb28ifTua1S8=" + } + ] + appliesTo: "server" + } + { + id: "DuplexClientErrorOutput" + protocol: restJson1 + events: [ + { + type: "response" + params: { + error: { message: "foo" } + } + headers: { + ":message-type": { string: "exception" } + ":exception-type": { string: "error" } + ":content-type": { string: "application/json" } + } + body: """ + {"message":"foo"}""" + bodyMediaType: "application/json" + bytes: "AAAAdAAAAFObEpkoDTptZXNzYWdlLXR5cGUHAAlleGNlcHRpb24POmV4Y2VwdGlvbi10eXBlBwAFZXJyb3INOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb257Im1lc3NhZ2UiOiJmb28ifTua1S8=" + } + ] + expectation: { + failure: { errorId: ErrorEvent } + } + appliesTo: "client" + } + { + id: "DuplexClientUnexpectedErrorOutput" + documentation: "Clients must be able to handle structured, but unmodeled errors." + protocol: restJson1 + events: [ + { + type: "request" + headers: { + ":message-type": { string: "error" } + ":error-code": { string: "internal-error" } + ":error-message": { string: "An unknown error occurred." } + } + bytes: "AAAAbwAAAF+FlHOQDTptZXNzYWdlLXR5cGUHAAVlcnJvcgs6ZXJyb3ItY29kZQcADmludGVybmFsLWVycm9yDjplcnJvci1tZXNzYWdlBwAaQW4gdW5rbm93biBlcnJvciBvY2N1cnJlZC4kun0t" + } + ] + expectation: { + failure: {} + } + appliesTo: "client" + } + { + id: "DuplexMissingMessageTypeOutput" + documentation: "Clients must reject events that don't contain a :message-type header." + protocol: restJson1 + events: [ + { + type: "response" + params: { + headersAndExplicitPayload: { + header: "foo" + payload: { structureMember: "bar" } + } + } + headers: { + ":event-type": { string: "headersAndExplicitPayload" } + ":content-type": { string: "application/json" } + header: { string: "foo" } + } + body: """ + {"structureMember":"bar"}""" + bodyMediaType: "application/json" + bytes: "AAAAfwAAAFacqFy2CzpldmVudC10eXBlBwAZaGVhZGVyc0FuZEV4cGxpY2l0UGF5bG9hZA06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbgZoZWFkZXIHAANmb297InN0cnVjdHVyZU1lbWJlciI6ImJhciJ98LexJg==" + } + ] + expectation: { + failure: {} + } + appliesTo: "client" + } + { + id: "DuplexMalformedMessageTypeOutput" + documentation: "Client must reject events that contain a malformed :message-type header." + protocol: restJson1 + events: [ + { + type: "response" + params: { + headersAndExplicitPayload: { + header: "foo" + payload: { structureMember: "bar" } + } + } + headers: { + ":message-type": { blob: "ZXZlbnQ=" } + ":event-type": { string: "headersAndExplicitPayload" } + ":content-type": { string: "application/json" } + header: { string: "foo" } + } + body: """ + {"structureMember":"bar"}""" + bodyMediaType: "application/json" + bytes: "AAAAlQAAAGw4wFp6DTptZXNzYWdlLXR5cGUGAAVldmVudAs6ZXZlbnQtdHlwZQcAGWhlYWRlcnNBbmRFeHBsaWNpdFBheWxvYWQNOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24GaGVhZGVyBwADZm9veyJzdHJ1Y3R1cmVNZW1iZXIiOiJiYXIifVwdfzU=" + } + ] + expectation: { + failure: {} + } + appliesTo: "client" + } + { + id: "DuplexMissingEventTypeOutput" + documentation: "Clients must reject message events that don't contain an :event-type header." + protocol: restJson1 + events: [ + { + type: "response" + params: { + headersAndExplicitPayload: { + header: "foo" + payload: { structureMember: "bar" } + } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { string: "headersAndExplicitPayload" } + ":content-type": { string: "application/json" } + header: { string: "foo" } + } + body: """ + {"structureMember":"bar"}""" + bodyMediaType: "application/json" + bytes: "AAAAlQAAAGw4wFp6DTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQcAGWhlYWRlcnNBbmRFeHBsaWNpdFBheWxvYWQNOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24GaGVhZGVyBwADZm9veyJzdHJ1Y3R1cmVNZW1iZXIiOiJiYXIifTafKXs=" + } + ] + expectation: { + failure: {} + } + appliesTo: "client" + } + { + id: "DuplexMalformedEventTypeOutput" + documentation: "Clients must reject message events that contain a malformed :event-type header." + protocol: restJson1 + events: [ + { + type: "response" + params: { + headersAndExplicitPayload: { + header: "foo" + payload: { structureMember: "bar" } + } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { blob: "aGVhZGVyc0FuZEV4cGxpY2l0UGF5bG9hZA==" } + ":content-type": { string: "application/json" } + header: { string: "foo" } + } + body: """ + {"structureMember":"bar"}""" + bodyMediaType: "application/json" + bytes: "AAAAlQAAAGw4wFp6DTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQYAGWhlYWRlcnNBbmRFeHBsaWNpdFBheWxvYWQNOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24GaGVhZGVyBwADZm9veyJzdHJ1Y3R1cmVNZW1iZXIiOiJiYXIifcP6KLk=" + } + ] + expectation: { + failure: {} + } + appliesTo: "client" + } +]) +@http(method: "POST", uri: "/DuplexStream") +operation DuplexStream { + input := { + @httpPayload + stream: EventStream + } + + output := { + @httpPayload + stream: EventStream + } +} + +@eventStreamTests([ + { + id: "InitialRequestInput" + protocol: restJson1 + initialRequestParams: { initialRequestMember: "foo" } + initialRequest: { + method: "POST" + uri: "/InputStreamWithInitialRequest" + headers: { "initial-request-member": "foo" } + } + initialRequestShape: InitialHttpRequest + } + { + id: "MissingRequiredInitialRequestInput" + protocol: restJson1 + events: [ + { + type: "request" + params: { + headers: { booleanHeader: true } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { string: "headers" } + booleanHeader: { boolean: true } + } + bytes: "AAAASwAAADv7Cl8VDTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQcAB2hlYWRlcnMNYm9vbGVhbkhlYWRlcgC4J9Ws" + } + ] + expectation: { + failure: {} + } + appliesTo: "server" + } +]) +@http(method: "POST", uri: "/InputStreamWithInitialRequest") +operation InputStreamWithInitialRequest { + input := { + @httpHeader("initial-request-member") + @required + initialRequestMember: String + + @httpPayload + stream: EventStream + } +} + +@eventStreamTests([ + { + id: "InitialResponseOutput" + protocol: restJson1 + initialResponseParams: { initialResponseMember: "foo" } + initialResponse: { + code: 200 + headers: { "initial-request-member": "foo" } + } + initialResponseShape: InitialHttpResponse + } + { + id: "MissingRequiredInitialResponseOutput" + protocol: restJson1 + events: [ + { + type: "response" + params: { + headers: { booleanHeader: true } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { string: "headers" } + booleanHeader: { boolean: true } + } + bytes: "AAAASwAAADv7Cl8VDTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQcAB2hlYWRlcnMNYm9vbGVhbkhlYWRlcgC4J9Ws" + } + ] + expectation: { + failure: {} + } + appliesTo: "client" + } +]) +@http(method: "POST", uri: "/OutputStreamWithInitialResponse") +operation OutputStreamWithInitialResponse { + output := { + @httpHeader("initial-response-member") + @required + initialResponseMember: String + + @httpPayload + stream: EventStream + } +} + +@eventStreamTests([ + { + id: "DuplexInitialRequestInput" + protocol: restJson1 + initialRequestParams: { initialRequestMember: "foo" } + initialRequest: { + method: "POST" + uri: "/DuplexStreamWithInitialMessages" + headers: { "initial-request-member": "foo" } + } + initialRequestShape: InitialHttpRequest + } + { + id: "DuplexMissingRequiredInitialRequestInput" + protocol: restJson1 + events: [ + { + type: "request" + params: { + headers: { booleanHeader: true } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { string: "headers" } + booleanHeader: { boolean: true } + } + bytes: "AAAASwAAADv7Cl8VDTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQcAB2hlYWRlcnMNYm9vbGVhbkhlYWRlcgC4J9Ws" + } + ] + expectation: { + failure: {} + } + appliesTo: "server" + } + { + id: "DuplexInitialResponseOutput" + protocol: restJson1 + initialResponseParams: { initialResponseMember: "foo" } + initialResponse: { + code: 200 + headers: { "initial-request-member": "foo" } + } + initialResponseShape: InitialHttpResponse + } + { + id: "DuplexMissingRequiredInitialResponseOutput" + protocol: restJson1 + events: [ + { + type: "response" + params: { + headers: { booleanHeader: true } + } + headers: { + ":message-type": { string: "event" } + ":event-type": { string: "headers" } + booleanHeader: { boolean: true } + } + bytes: "AAAASwAAADv7Cl8VDTptZXNzYWdlLXR5cGUHAAVldmVudAs6ZXZlbnQtdHlwZQcAB2hlYWRlcnMNYm9vbGVhbkhlYWRlcgC4J9Ws" + } + ] + expectation: { + failure: {} + } + appliesTo: "client" + } + { + id: "DuplexModeledProtocolError" + protocol: restJson1 + initialResponse: { + code: 500 + headers: { "Content-Type": "application/json", "X-Amzn-Errortype": "ServiceUnavailableError" } + body: """ + {"message": "foo"}""" + bodyMediaType: "application/json" + } + initialResponseShape: InitialHttpResponse + expectation: { + failure: { errorId: ServiceUnavailableError } + } + appliesTo: "client" + } + { + id: "DuplexUnmodeledProtocolError" + protocol: restJson1 + initialResponse: { + code: 500 + headers: { "Content-Type": "text/plain" } + body: "service unavailable" + bodyMediaType: "text/plain" + } + initialResponseShape: InitialHttpResponse + expectation: { + failure: {} + } + appliesTo: "client" + } +]) +@http(method: "POST", uri: "/DuplexStreamWithInitialMessages") +operation DuplexStreamWithInitialMessages { + input := { + @httpHeader("initial-request-member") + @required + initialRequestMember: String + + @httpPayload + stream: EventStream + } + + output := { + @httpHeader("initial-response-member") + @required + initialResponseMember: String + + @httpPayload + stream: EventStream + } + + errors: [ + ServiceUnavailableError + ] +} + +@http(method: "POST", uri: "/DuplexStreamWithDistinctStreams") +operation DuplexStreamWithDistinctStreams { + input := { + @httpPayload + stream: EventStream + } + + output := { + @httpPayload + stream: SingletonEventStream + } +} + +union SingletonEventStream { + singleton: SingletonEvent +} + +structure SingletonEvent { + value: String +} diff --git a/smithy-aws-protocol-tests/model/restJson1/main.smithy b/smithy-aws-protocol-tests/model/restJson1/main.smithy index 6630713b24c..0ec801ea9b9 100644 --- a/smithy-aws-protocol-tests/model/restJson1/main.smithy +++ b/smithy-aws-protocol-tests/model/restJson1/main.smithy @@ -5,167 +5,153 @@ namespace aws.protocoltests.restjson use aws.api#service use aws.auth#sigv4 use aws.protocols#restJson1 -use smithy.test#httpRequestTests -use smithy.test#httpResponseTests -/// A REST JSON service that sends JSON requests and responses. @service(sdkId: "Rest Json Protocol") @sigv4(name: "restjson") @restJson1 @title("Sample Rest Json Protocol Service") service RestJson { - version: "2019-12-16", + version: "2019-12-16" // Ensure that generators are able to handle renames. rename: { - "aws.protocoltests.restjson.nested#GreetingStruct": "RenamedGreeting", - }, + "aws.protocoltests.restjson.nested#GreetingStruct": "RenamedGreeting" + } operations: [ // Basic input and output tests - NoInputAndNoOutput, - NoInputAndOutput, - EmptyInputAndEmptyOutput, - UnitInputAndOutput, - + NoInputAndNoOutput + NoInputAndOutput + EmptyInputAndEmptyOutput + UnitInputAndOutput // @httpHeader tests - InputAndOutputWithHeaders, - NullAndEmptyHeadersClient, - NullAndEmptyHeadersServer, - TimestampFormatHeaders, - MediaTypeHeader, - + InputAndOutputWithHeaders + NullAndEmptyHeadersClient + NullAndEmptyHeadersServer + TimestampFormatHeaders + MediaTypeHeader // @httpLabel tests - HttpRequestWithLabels, - HttpRequestWithLabelsAndTimestampFormat, - HttpRequestWithGreedyLabelInPath, - HttpRequestWithFloatLabels, - HttpRequestWithRegexLiteral, - + HttpRequestWithLabels + HttpRequestWithLabelsAndTimestampFormat + HttpRequestWithGreedyLabelInPath + HttpRequestWithFloatLabels + HttpRequestWithRegexLiteral // @httpQuery and @httpQueryParams tests - AllQueryStringTypes, - ConstantQueryString, - ConstantAndVariableQueryString, - IgnoreQueryParamsInResponse, - OmitsNullSerializesEmptyString, - OmitsSerializingEmptyLists, - QueryIdempotencyTokenAutoFill, - QueryPrecedence, - HttpQueryParamsOnlyOperation, - QueryParamsAsStringListMap, + AllQueryStringTypes + ConstantQueryString + ConstantAndVariableQueryString + IgnoreQueryParamsInResponse + OmitsNullSerializesEmptyString + OmitsSerializingEmptyLists + QueryIdempotencyTokenAutoFill + QueryPrecedence + HttpQueryParamsOnlyOperation + QueryParamsAsStringListMap // @httpPrefixHeaders tests - HttpPrefixHeaders, - HttpPrefixHeadersInResponse, - HttpEmptyPrefixHeaders, - + HttpPrefixHeaders + HttpPrefixHeadersInResponse + HttpEmptyPrefixHeaders // @httpPayload tests - HttpPayloadTraits, - HttpPayloadTraitsWithMediaType, - HttpPayloadWithStructure, - HttpEnumPayload, - HttpStringPayload, - HttpPayloadWithUnion, - + HttpPayloadTraits + HttpPayloadTraitsWithMediaType + HttpPayloadWithStructure + HttpEnumPayload + HttpStringPayload + HttpPayloadWithUnion // @httpResponseCode tests - HttpResponseCode, + HttpResponseCode ResponseCodeRequired ResponseCodeHttpFallback - // @streaming tests - StreamingTraits, - StreamingTraitsRequireLength, - StreamingTraitsWithMediaType, - + StreamingTraits + StreamingTraitsRequireLength + StreamingTraitsWithMediaType // Errors - GreetingWithErrors, - + GreetingWithErrors // Synthesized JSON document body tests - SimpleScalarProperties, - JsonTimestamps, - JsonEnums, - JsonIntEnums, - RecursiveShapes, - JsonLists, - SparseJsonLists, - JsonMaps, - SparseJsonMaps, - JsonBlobs, - + SimpleScalarProperties + JsonTimestamps + JsonEnums + JsonIntEnums + RecursiveShapes + JsonLists + SparseJsonLists + JsonMaps + SparseJsonMaps + JsonBlobs // Documents - DocumentType, - DocumentTypeAsPayload, - DocumentTypeAsMapValue, - + DocumentType + DocumentTypeAsPayload + DocumentTypeAsMapValue // Unions - JsonUnions, - PostPlayerAction, - PostUnionWithJsonName, - + JsonUnions + PostPlayerAction + PostUnionWithJsonName // @endpoint and @hostLabel trait tests - EndpointOperation, - EndpointWithHostLabelOperation, - + EndpointOperation + EndpointWithHostLabelOperation // custom endpoints with paths - HostWithPathOperation, - + HostWithPathOperation // checksum(s) - HttpChecksumRequired, - + HttpChecksumRequired // malformed request tests - MalformedRequestBody, - MalformedInteger, - MalformedUnion, - MalformedBoolean, - MalformedList, - MalformedMap, - MalformedBlob, - MalformedByte, - MalformedShort, - MalformedLong, - MalformedFloat, - MalformedDouble, - MalformedString, - MalformedTimestampPathDefault, - MalformedTimestampPathHttpDate, - MalformedTimestampPathEpoch, - MalformedTimestampQueryDefault, - MalformedTimestampQueryHttpDate, - MalformedTimestampQueryEpoch, - MalformedTimestampHeaderDefault, - MalformedTimestampHeaderDateTime, - MalformedTimestampHeaderEpoch, - MalformedTimestampBodyDefault, - MalformedTimestampBodyDateTime, - MalformedTimestampBodyHttpDate, - MalformedContentTypeWithoutBody, + MalformedRequestBody + MalformedInteger + MalformedUnion + MalformedBoolean + MalformedList + MalformedMap + MalformedBlob + MalformedByte + MalformedShort + MalformedLong + MalformedFloat + MalformedDouble + MalformedString + MalformedTimestampPathDefault + MalformedTimestampPathHttpDate + MalformedTimestampPathEpoch + MalformedTimestampQueryDefault + MalformedTimestampQueryHttpDate + MalformedTimestampQueryEpoch + MalformedTimestampHeaderDefault + MalformedTimestampHeaderDateTime + MalformedTimestampHeaderEpoch + MalformedTimestampBodyDefault + MalformedTimestampBodyDateTime + MalformedTimestampBodyHttpDate + MalformedContentTypeWithoutBody MalformedContentTypeWithoutBodyEmptyInput - MalformedContentTypeWithBody, - MalformedContentTypeWithPayload, - MalformedContentTypeWithGenericString, - MalformedAcceptWithBody, - MalformedAcceptWithPayload, - MalformedAcceptWithGenericString, - + MalformedContentTypeWithBody + MalformedContentTypeWithPayload + MalformedContentTypeWithGenericString + MalformedAcceptWithBody + MalformedAcceptWithPayload + MalformedAcceptWithGenericString // request body and content-type handling - TestBodyStructure, - TestPayloadStructure, - TestPayloadBlob, + TestBodyStructure + TestPayloadStructure + TestPayloadBlob TestGetNoPayload - TestPostNoPayload, - TestGetNoInputNoPayload, - TestPostNoInputNoPayload, - + TestPostNoPayload + TestGetNoInputNoPayload + TestPostNoInputNoPayload // client-only timestamp parsing tests - DatetimeOffsets, - FractionalSeconds, - + DatetimeOffsets + FractionalSeconds // requestCompression trait tests - PutWithContentEncoding, - + PutWithContentEncoding // Content-Type header tests - ContentTypeParameters, - + ContentTypeParameters // defaults OperationWithDefaults OperationWithNestedStructure + // Event streaming + InputStream + OutputStream + DuplexStream + InputStreamWithInitialRequest + OutputStreamWithInitialResponse + DuplexStreamWithInitialMessages + DuplexStreamWithDistinctStreams ] } diff --git a/smithy-model/src/main/java/software/amazon/smithy/model/node/DefaultNodeDeserializers.java b/smithy-model/src/main/java/software/amazon/smithy/model/node/DefaultNodeDeserializers.java index e204d712f71..eda9bcc876b 100644 --- a/smithy-model/src/main/java/software/amazon/smithy/model/node/DefaultNodeDeserializers.java +++ b/smithy-model/src/main/java/software/amazon/smithy/model/node/DefaultNodeDeserializers.java @@ -20,9 +20,11 @@ import java.math.BigInteger; import java.net.URI; import java.net.URL; +import java.nio.charset.StandardCharsets; import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; +import java.util.Base64; import java.util.Collection; import java.util.HashMap; import java.util.HashSet; @@ -603,6 +605,21 @@ private interface FromStringClassFactory { }; }; + // Creates a byte array from a base64-encoded string node. + private static final ObjectCreatorFactory BYTES_CREATOR = (nodeType, target, nodeMapper) -> { + if (nodeType != NodeType.STRING || target != byte[].class) { + return null; + } + return (node, targetType, pointer, mapper) -> { + String value = node.expectStringNode().getValue(); + try { + return Base64.getDecoder().decode(value.getBytes(StandardCharsets.UTF_8)); + } catch (Exception e) { + throw NodeDeserializationException.fromContext(targetType, pointer, node, e, e.getMessage()); + } + }; + }; + // The priority ordered list of default factories that NodeMapper uses. // The priority is determined based on the specificity of each deserializer; // the most specific ones should appear at the start of the list, and the @@ -615,6 +632,7 @@ private interface FromStringClassFactory { NULL_CREATOR, FROM_NODE_CREATOR, BOOLEAN_CREATOR_FACTORY, + BYTES_CREATOR, FROM_STRING, STRING_CREATOR, ENUM_CREATOR, diff --git a/smithy-model/src/main/java/software/amazon/smithy/model/node/DefaultNodeSerializers.java b/smithy-model/src/main/java/software/amazon/smithy/model/node/DefaultNodeSerializers.java index 28a888a9b38..1a2e78a0ec4 100644 --- a/smithy-model/src/main/java/software/amazon/smithy/model/node/DefaultNodeSerializers.java +++ b/smithy-model/src/main/java/software/amazon/smithy/model/node/DefaultNodeSerializers.java @@ -14,6 +14,7 @@ import java.net.URL; import java.nio.file.Path; import java.util.ArrayList; +import java.util.Base64; import java.util.Comparator; import java.util.HashSet; import java.util.LinkedHashMap; @@ -84,6 +85,19 @@ public Node serialize(Number value, Set serializedObjects, NodeMapper ma } }; + // Serializes a byte array into a base64-encoded string node. + private static final Serializer BLOB_SERIALIZER = new Serializer() { + @Override + public Class getType() { + return byte[].class; + } + + @Override + public Node serialize(byte[] value, Set serializedObjects, NodeMapper mapper) { + return Node.from(Base64.getEncoder().encodeToString(value)); + } + }; + // Serialize a String into a StringNode. private static final Serializer STRING_SERIALIZER = new Serializer() { @Override @@ -392,6 +406,7 @@ private boolean canSerialize(NodeMapper mapper, Node value) { static final List SERIALIZERS = ListUtils.of( TO_NODE_SERIALIZER, OPTIONAL_SERIALIZER, + BLOB_SERIALIZER, STRING_SERIALIZER, BOOLEAN_SERIALIZER, NUMBER_SERIALIZER, diff --git a/smithy-model/src/test/java/software/amazon/smithy/model/node/NodeMapperTest.java b/smithy-model/src/test/java/software/amazon/smithy/model/node/NodeMapperTest.java index 2c056a2ebfa..4303452a652 100644 --- a/smithy-model/src/test/java/software/amazon/smithy/model/node/NodeMapperTest.java +++ b/smithy-model/src/test/java/software/amazon/smithy/model/node/NodeMapperTest.java @@ -16,10 +16,12 @@ import static org.hamcrest.Matchers.not; import static org.hamcrest.Matchers.nullValue; import static org.hamcrest.Matchers.startsWith; +import static org.junit.jupiter.api.Assertions.assertEquals; import java.io.File; import java.net.URI; import java.net.URL; +import java.nio.charset.StandardCharsets; import java.nio.file.Path; import java.util.ArrayList; import java.util.Arrays; @@ -1634,4 +1636,34 @@ public Map>> getShapeTypes() { return shapeTypes; } } + + @Test + public void serializesBytesToBase64StringNode() { + NodeMapper mapper = new NodeMapper(); + Node input = Node.objectNode().withMember("bytes", "Zm9v"); + HasBytes result = mapper.deserialize(input, HasBytes.class); + String decoded = new String(result.getBytes(), StandardCharsets.UTF_8); + assertEquals("foo", decoded); + } + + @Test + public void deserializesBytesFromBase64StringNode() { + NodeMapper mapper = new NodeMapper(); + HasBytes input = new HasBytes(); + input.setBytes("foo".getBytes(StandardCharsets.UTF_8)); + ObjectNode result = mapper.serialize(input).expectObjectNode(); + assertEquals("Zm9v", result.expectStringMember("bytes").getValue()); + } + + public static final class HasBytes { + private byte[] bytes; + + public byte[] getBytes() { + return bytes; + } + + public void setBytes(byte[] bytes) { + this.bytes = bytes; + } + } } diff --git a/smithy-protocol-test-traits/src/main/java/software/amazon/smithy/protocoltests/traits/ProtocolTestCaseValidator.java b/smithy-protocol-test-traits/src/main/java/software/amazon/smithy/protocoltests/traits/ProtocolTestCaseValidator.java index cb905d84c82..69547202265 100644 --- a/smithy-protocol-test-traits/src/main/java/software/amazon/smithy/protocoltests/traits/ProtocolTestCaseValidator.java +++ b/smithy-protocol-test-traits/src/main/java/software/amazon/smithy/protocoltests/traits/ProtocolTestCaseValidator.java @@ -4,20 +4,12 @@ */ 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 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; @@ -29,7 +21,7 @@ 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; +import software.amazon.smithy.utils.ListUtils; /** * Validates the following: @@ -48,24 +40,11 @@ abstract class ProtocolTestCaseValidator extends AbstractValida private final Class traitClass; private final ShapeId traitId; private final String descriptor; - private final DocumentBuilderFactory documentBuilderFactory; ProtocolTestCaseValidator(ShapeId traitId, Class traitClass, String descriptor) { this.traitId = traitId; this.traitClass = traitClass; this.descriptor = descriptor; - documentBuilderFactory = DocumentBuilderFactory.newInstance(); - - // Disallow loading DTDs and more for protocol test contents. - try { - documentBuilderFactory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); - documentBuilderFactory.setXIncludeAware(false); - documentBuilderFactory.setExpandEntityReferences(false); - documentBuilderFactory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); - documentBuilderFactory.setFeature("http://xml.org/sax/features/external-general-entities", false); - } catch (ParserConfigurationException e) { - throw new RuntimeException(e); - } } @Override @@ -162,49 +141,13 @@ private NodeValidationVisitor createVisitor( } private List 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()) { + if (!test.getBodyMediaType().isPresent()) { return Collections.emptyList(); } - String rawMediaType = test.getBodyMediaType().orElse("application/octet-stream"); - MediaType mediaType = MediaType.from(rawMediaType); - List 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 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 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)); - } + return ProtocolTestValidationUtils.validateMediaType(test.getBody().orElse(""), test.getBodyMediaType().get()) + .map(e -> ListUtils.of(emitMediaTypeError(shape, trait, test, e))) + .orElse(Collections.emptyList()); } private ValidationEvent emitMediaTypeError(Shape shape, Trait trait, HttpMessageTestCase test, Throwable e) { diff --git a/smithy-protocol-test-traits/src/main/java/software/amazon/smithy/protocoltests/traits/ProtocolTestValidationUtils.java b/smithy-protocol-test-traits/src/main/java/software/amazon/smithy/protocoltests/traits/ProtocolTestValidationUtils.java new file mode 100644 index 00000000000..a34562cbe1b --- /dev/null +++ b/smithy-protocol-test-traits/src/main/java/software/amazon/smithy/protocoltests/traits/ProtocolTestValidationUtils.java @@ -0,0 +1,93 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package software.amazon.smithy.protocoltests.traits; + +import java.io.IOException; +import java.io.StringReader; +import java.util.Optional; +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.loader.ModelSyntaxException; +import software.amazon.smithy.model.node.Node; +import software.amazon.smithy.utils.MediaType; +import software.amazon.smithy.utils.StringUtils; + +/** + * Shared validation utility functions for protocol tests. + */ +public final class ProtocolTestValidationUtils { + private static final DocumentBuilderFactory DOCUMENT_BUILDER_FACTORY = DocumentBuilderFactory.newInstance(); + + static { + // Disallow loading DTDs and more for protocol test contents. + try { + DOCUMENT_BUILDER_FACTORY.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + DOCUMENT_BUILDER_FACTORY.setXIncludeAware(false); + DOCUMENT_BUILDER_FACTORY.setExpandEntityReferences(false); + DOCUMENT_BUILDER_FACTORY.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + DOCUMENT_BUILDER_FACTORY.setFeature("http://xml.org/sax/features/external-general-entities", false); + } catch (ParserConfigurationException e) { + throw new RuntimeException(e); + } + } + + private ProtocolTestValidationUtils() {} + + /** + * Checks whether a body is well-formed according to its mediaType. + * + *

If the body is not well-formed, a exception with context will be returned. + * + *

Currently XML and JSON validation are supported. + * + * @param body The body to validate. + * @param rawMediaType The mediaType to validate the body with. + * @return Returns an Optional Exception if the body is not valid. + */ + public static Optional validateMediaType(String body, String rawMediaType) { + if (StringUtils.isEmpty(body) || StringUtils.isEmpty(rawMediaType)) { + return Optional.empty(); + } + + MediaType mediaType = MediaType.from(rawMediaType); + if (isXml(mediaType)) { + return validateXml(body); + } else if (isJson(mediaType)) { + return validateJson(body); + } + + return Optional.empty(); + } + + private static boolean isXml(MediaType mediaType) { + return mediaType.getSubtype().equals("xml") || mediaType.getSuffix().orElse("").equals("xml"); + } + + private static Optional validateXml(String body) { + try { + DocumentBuilder builder = DOCUMENT_BUILDER_FACTORY.newDocumentBuilder(); + builder.parse(new InputSource(new StringReader(body))); + return Optional.empty(); + } catch (ParserConfigurationException | SAXException | IOException e) { + return Optional.of(e); + } + } + + private static boolean isJson(MediaType mediaType) { + return mediaType.getSubtype().equals("json") || mediaType.getSuffix().orElse("").equals("json"); + } + + private static Optional validateJson(String body) { + try { + Node.parse(body); + return Optional.empty(); + } catch (ModelSyntaxException e) { + return Optional.of(e); + } + } +} diff --git a/smithy-protocol-test-traits/src/main/java/software/amazon/smithy/protocoltests/traits/TestExpectation.java b/smithy-protocol-test-traits/src/main/java/software/amazon/smithy/protocoltests/traits/TestExpectation.java new file mode 100644 index 00000000000..45b7b53a5b4 --- /dev/null +++ b/smithy-protocol-test-traits/src/main/java/software/amazon/smithy/protocoltests/traits/TestExpectation.java @@ -0,0 +1,119 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package software.amazon.smithy.protocoltests.traits; + +import java.util.Optional; +import software.amazon.smithy.model.node.ExpectationNotMetException; +import software.amazon.smithy.model.node.Node; +import software.amazon.smithy.model.node.ObjectNode; +import software.amazon.smithy.model.node.ToNode; +import software.amazon.smithy.model.validation.ValidationUtils; +import software.amazon.smithy.utils.ListUtils; + +/** + * Defines the expected result of a test case. + * + *

This can either be a successful response, any error response, or a + * specific error response. + */ +public final class TestExpectation implements ToNode { + private static final String SUCCESS = "success"; + private static final String FAILURE = "failure"; + + private final TestFailureExpectation failure; + + private TestExpectation(TestFailureExpectation failure) { + this.failure = failure; + } + + /** + * @return Creates an expectation that the service call for a smoke + * test case is successful. + */ + public static TestExpectation success() { + return new TestExpectation(null); + } + + /** + * @param failure The failure to expect. + * @return Creates an expectation that the service call for a smoke test + * case will result in the given failure. + */ + public static TestExpectation failure(TestFailureExpectation failure) { + return new TestExpectation(failure); + } + + /** + * Creates a {@link TestExpectation} from a {@link Node}. + * + * @param node Node to deserialize into a {@link TestExpectation}. + * @return Returns the created {@link TestExpectation}. + */ + public static TestExpectation fromNode(Node node) { + ObjectNode o = node.expectObjectNode(); + if (o.containsMember(SUCCESS)) { + o.expectNoAdditionalProperties(ListUtils.of(SUCCESS)); + return TestExpectation.success(); + } else if (o.containsMember(FAILURE)) { + o.expectNoAdditionalProperties(ListUtils.of(FAILURE)); + TestFailureExpectation failure = TestFailureExpectation.fromNode(o.expectObjectMember(FAILURE)); + return TestExpectation.failure(failure); + } else { + throw new ExpectationNotMetException("Expected an object with exactly one `" + SUCCESS + "` or `" + FAILURE + + "` property, but found properties: " + ValidationUtils.tickedList(o.getStringMap().keySet()), o); + } + } + + /** + * @return Whether the service call is expected to succeed. + */ + public boolean isSuccess() { + return failure == null; + } + + /** + * @return Whether the service call is expected to fail. + */ + public boolean isFailure() { + return failure != null; + } + + /** + * @return The expected failure, if this expectation is a failure expectation. + */ + public Optional getFailure() { + return Optional.ofNullable(failure); + } + + @Override + public Node toNode() { + ObjectNode.Builder builder = Node.objectNodeBuilder(); + if (this.isSuccess()) { + builder.withMember(SUCCESS, Node.objectNode()); + } else { + Node failureNode = this.getFailure() + .map(TestFailureExpectation::toNode) + .orElse(Node.objectNode()); + builder.withMember(FAILURE, failureNode); + } + return builder.build(); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } else if (o == null || o.getClass() != getClass()) { + return false; + } else { + return toNode().equals(((TestExpectation) o).toNode()); + } + } + + @Override + public int hashCode() { + return toNode().hashCode(); + } +} diff --git a/smithy-protocol-test-traits/src/main/java/software/amazon/smithy/protocoltests/traits/TestFailureExpectation.java b/smithy-protocol-test-traits/src/main/java/software/amazon/smithy/protocoltests/traits/TestFailureExpectation.java new file mode 100644 index 00000000000..3b7608db78d --- /dev/null +++ b/smithy-protocol-test-traits/src/main/java/software/amazon/smithy/protocoltests/traits/TestFailureExpectation.java @@ -0,0 +1,88 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package software.amazon.smithy.protocoltests.traits; + +import java.util.Optional; +import software.amazon.smithy.model.node.Node; +import software.amazon.smithy.model.node.ObjectNode; +import software.amazon.smithy.model.node.StringNode; +import software.amazon.smithy.model.node.ToNode; +import software.amazon.smithy.model.shapes.ShapeId; + +/** + * Defines the expected failure of a test. + * + *

This can be any error response, or a specific error response. + */ +public final class TestFailureExpectation implements ToNode { + private static final String ERROR_ID = "errorId"; + + private final ShapeId errorId; + + private TestFailureExpectation(ShapeId errorId) { + this.errorId = errorId; + } + + /** + * @return Returns an expectation that a test will fail with an unspecified error. + */ + public static TestFailureExpectation anyError() { + return new TestFailureExpectation(null); + } + + /** + * Create an expectation that a test will fail with a specified error. + * + * @param errorId Shape ID of the expected error. + * + * @return Returns a specific error expectation. + */ + public static TestFailureExpectation errorWithId(ShapeId errorId) { + return new TestFailureExpectation(errorId); + } + + /** + * Gets the ID of the expected error shape. + * + *

If present, it indicates the test should throw a matching error. + * Otherwise, the test should throw any error. + * + * @return The ID of the expected error shape. + */ + public Optional getErrorId() { + return Optional.ofNullable(errorId); + } + + @Override + public Node toNode() { + return Node.objectNodeBuilder() + .withOptionalMember(ERROR_ID, this.getErrorId().map(ShapeId::toString).map(StringNode::from)) + .build(); + } + + public static TestFailureExpectation fromNode(Node node) { + ObjectNode o = node.expectObjectNode(); + return o.getStringMember(ERROR_ID) + .map(ShapeId::fromNode) + .map(TestFailureExpectation::errorWithId) + .orElse(TestFailureExpectation.anyError()); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } else if (o == null || o.getClass() != getClass()) { + return false; + } else { + return toNode().equals(((TestFailureExpectation) o).toNode()); + } + } + + @Override + public int hashCode() { + return toNode().hashCode(); + } +} diff --git a/smithy-protocol-test-traits/src/main/java/software/amazon/smithy/protocoltests/traits/UniqueProtocolTestCaseIdValidator.java b/smithy-protocol-test-traits/src/main/java/software/amazon/smithy/protocoltests/traits/UniqueProtocolTestCaseIdValidator.java index 65ab9b5668c..560a9e92978 100644 --- a/smithy-protocol-test-traits/src/main/java/software/amazon/smithy/protocoltests/traits/UniqueProtocolTestCaseIdValidator.java +++ b/smithy-protocol-test-traits/src/main/java/software/amazon/smithy/protocoltests/traits/UniqueProtocolTestCaseIdValidator.java @@ -18,6 +18,8 @@ import software.amazon.smithy.model.validation.AbstractValidator; import software.amazon.smithy.model.validation.ValidationEvent; import software.amazon.smithy.model.validation.ValidationUtils; +import software.amazon.smithy.protocoltests.traits.eventstream.EventStreamTestCase; +import software.amazon.smithy.protocoltests.traits.eventstream.EventStreamTestsTrait; import software.amazon.smithy.utils.SmithyInternalApi; /** @@ -33,6 +35,7 @@ public List validate(Model model) { Map> requestIdsToTraits = new TreeMap<>(); Map> responseIdsToTraits = new TreeMap<>(); Map> malformedRequestIdsToTraits = new TreeMap<>(); + Map> eventCaseIdsToTraits = new TreeMap<>(); Stream.concat(model.shapes(OperationShape.class), model.shapes(StructureShape.class)).forEach(shape -> { shape.getTrait(HttpRequestTestsTrait.class) @@ -43,13 +46,19 @@ public List validate(Model model) { // in case someone does something wild with naming, like add _case0 to the end of the id shape.getTrait(HttpMalformedRequestTestsTrait.class) .ifPresent(t -> addMalformedRequestTestCaseIdsToMap(shape, t.getTestCases(), responseIdsToTraits)); + shape.getTrait(EventStreamTestsTrait.class) + .ifPresent(trait -> addEventTestCaseIdsToMap(shape, trait.getTestCases(), eventCaseIdsToTraits)); }); removeEntriesWithSingleValue(requestIdsToTraits); removeEntriesWithSingleValue(responseIdsToTraits); removeEntriesWithSingleValue(malformedRequestIdsToTraits); + removeEntriesWithSingleValue(eventCaseIdsToTraits); - return collectEvents(requestIdsToTraits, responseIdsToTraits, malformedRequestIdsToTraits); + return collectEvents(requestIdsToTraits, + responseIdsToTraits, + malformedRequestIdsToTraits, + eventCaseIdsToTraits); } private void addTestCaseIdsToMap( @@ -72,6 +81,16 @@ private void addMalformedRequestTestCaseIdsToMap( } } + private void addEventTestCaseIdsToMap( + Shape shape, + List testCases, + Map> map + ) { + for (EventStreamTestCase testCase : testCases) { + map.computeIfAbsent(testCase.getId(), id -> new ArrayList<>()).add(shape); + } + } + private void removeEntriesWithSingleValue(Map> map) { map.keySet().removeIf(key -> map.get(key).size() == 1); } @@ -79,9 +98,13 @@ private void removeEntriesWithSingleValue(Map> map) { private List collectEvents( Map> requestIdsToTraits, Map> responseIdsToTraits, - Map> malformedRequestIdsToTraits + Map> malformedRequestIdsToTraits, + Map> eventCaseIdsToTraits ) { - if (requestIdsToTraits.isEmpty() && responseIdsToTraits.isEmpty() && malformedRequestIdsToTraits.isEmpty()) { + if (requestIdsToTraits.isEmpty() + && responseIdsToTraits.isEmpty() + && malformedRequestIdsToTraits.isEmpty() + && eventCaseIdsToTraits.isEmpty()) { return Collections.emptyList(); } @@ -89,6 +112,7 @@ private List collectEvents( addValidationEvents(requestIdsToTraits, mutableEvents, HttpRequestTestsTrait.ID); addValidationEvents(responseIdsToTraits, mutableEvents, HttpResponseTestsTrait.ID); addValidationEvents(malformedRequestIdsToTraits, mutableEvents, HttpMalformedRequestTestsTrait.ID); + addValidationEvents(eventCaseIdsToTraits, mutableEvents, EventStreamTestsTrait.ID); return mutableEvents; } diff --git a/smithy-protocol-test-traits/src/main/java/software/amazon/smithy/protocoltests/traits/eventstream/Event.java b/smithy-protocol-test-traits/src/main/java/software/amazon/smithy/protocoltests/traits/eventstream/Event.java new file mode 100644 index 00000000000..e44afaa0b1c --- /dev/null +++ b/smithy-protocol-test-traits/src/main/java/software/amazon/smithy/protocoltests/traits/eventstream/Event.java @@ -0,0 +1,266 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package software.amazon.smithy.protocoltests.traits.eventstream; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import software.amazon.smithy.model.node.ObjectNode; +import software.amazon.smithy.model.shapes.ShapeId; +import software.amazon.smithy.utils.BuilderRef; +import software.amazon.smithy.utils.SmithyBuilder; +import software.amazon.smithy.utils.ToSmithyBuilder; + +/** + * An event sent over the event stream. + */ +public final class Event implements ToSmithyBuilder { + private final EventType type; + private final ObjectNode params; + private final Map> headers; + private final List forbidHeaders; + private final List requireHeaders; + private final String body; + private final String bodyMediaType; + private final byte[] bytes; + private final ObjectNode vendorParams; + private final ShapeId vendorParamsShape; + + private Event(Builder builder) { + this.type = SmithyBuilder.requiredState("type", builder.type); + this.params = builder.params; + this.headers = builder.headers.copy(); + this.forbidHeaders = builder.forbidHeaders.copy(); + this.requireHeaders = builder.requireHeaders.copy(); + this.body = builder.body; + this.bodyMediaType = builder.bodyMediaType; + this.bytes = builder.bytes; + this.vendorParams = builder.vendorParams; + this.vendorParamsShape = builder.vendorParamsShape; + } + + /** + * @return Returns the type of event. + */ + public EventType getType() { + return type; + } + + /** + * Gets the optional parameters used to generate the event. + * + *

If set, these parameters MUST be compatible with a modeled event. + * If not set, this event represents an unmodeled event. + * + * @return Returns the optional parameters used to generate the event. + */ + public Optional getParams() { + return Optional.ofNullable(params); + } + + /** + * Gets a map of expected headers. + * + *

Headers that are not listed in this map are ignored unless they are + * explicitly forbidden through {@link #forbidHeaders}. + * + * @return Returns a map of expected headers. + */ + public Map> getHeaders() { + return headers; + } + + /** + * @return Returns a list of headers field names that MUST NOT appear in + * the serialized event. + */ + public List getForbidHeaders() { + return forbidHeaders; + } + + /** + * Gets a list of header field names that MUST appear in the serialized event. + * + *

No assertion is made on the value of the headers. + * + *

Headers listed in {@link #headers} do not need to appear in this list. + * @return Returns a list of required header keys. + */ + public List getRequireHeaders() { + return requireHeaders; + } + + /** + * Gets the optional expected event body. + * + *

If no request body is defined, then no assertions are made about the + * body of the event. + * + * @return Returns the optional expected event body. + */ + public Optional getBody() { + return Optional.ofNullable(body); + } + + /** + * Gets the optional media type of the {@link #body}. + * + *

This is used to help test runners parse and validate the expected + * data against generated data. + * + * @return Returns the optional media type of the event body. + */ + public Optional getBodyMediaType() { + return Optional.ofNullable(bodyMediaType); + } + + /** + * Gets an optional binary representation of the entire event. + * + *

This is used to test deserialization. If set, implementations SHOULD + * use this value to represent the binary value of received events rather + * than constructing that binary value from the other properties of the + * event. + * + *

This value SHOULD NOT be used to make assertions about serialized + * events as such assertions likely would not be reliable. They would + * suffer from the same problems of making {@link #body} assertions without a + * {@link #bodyMediaType} where nonspecified ordering and optional whitespace + * can cause semantically equivalent values to have different bytes. This + * is made worse by headers having no defined order, and is likely made + * even worse by common event framing features such as checksums. + * + * @return Returns an optional binary representation of the entire event. + */ + public Optional getBytes() { + return Optional.of(bytes); + } + + /** + * Gets vendor-specific params used to influence the request. + * + *

For example, some vendors might utilize environment variables, + * configuration files on disk, or other means to influence the + * serialization formats used by clients or servers. + * + *

If {@link #vendorParamsShape} is set, this MUST be compatible with + * that shape's definition. + * + * @return Returns a map of vendor-specific params used to influence the request. + */ + public Optional getVendorParams() { + return Optional.ofNullable(vendorParams); + } + + /** + * @return Returns an optional shape used to validate {@link #vendorParams}. + */ + public Optional getVendorParamsShape() { + return Optional.ofNullable(vendorParamsShape); + } + + @Override + public SmithyBuilder toBuilder() { + return new Builder() + .type(type) + .params(params) + .headers(headers) + .forbidHeaders(forbidHeaders) + .requireHeaders(requireHeaders) + .body(body) + .bodyMediaType(bodyMediaType) + .bytes(bytes) + .vendorParams(vendorParams) + .vendorParamsShape(vendorParamsShape); + } + + /** + * @return Returns a newly-created builder for {@link Event}. + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Builder used to create {@link Event}. + */ + public static final class Builder implements SmithyBuilder { + private EventType type; + private ObjectNode params; + private final BuilderRef>> headers = BuilderRef.forOrderedMap(); + private final BuilderRef> forbidHeaders = BuilderRef.forList(); + private final BuilderRef> requireHeaders = BuilderRef.forList(); + private String body; + private String bodyMediaType; + private byte[] bytes; + private ObjectNode vendorParams; + private ShapeId vendorParamsShape; + + @Override + public Event build() { + return new Event(this); + } + + public Builder type(EventType type) { + this.type = type; + return this; + } + + public Builder params(ObjectNode params) { + this.params = params; + return this; + } + + public Builder headers(Map> headers) { + this.headers.clear(); + this.headers.get().putAll(headers); + return this; + } + + public Builder forbidHeaders(List forbidHeaders) { + this.forbidHeaders.clear(); + this.forbidHeaders.get().addAll(forbidHeaders); + return this; + } + + public Builder requireHeaders(List requireHeaders) { + this.requireHeaders.clear(); + this.requireHeaders.get().addAll(requireHeaders); + return this; + } + + public Builder body(String body) { + this.body = body; + return this; + } + + public Builder bodyMediaType(String bodyMediaType) { + this.bodyMediaType = bodyMediaType; + return this; + } + + public Builder bytes(String bytes) { + this.bytes = Base64.getDecoder().decode(bytes.getBytes(StandardCharsets.UTF_8)); + return this; + } + + public Builder bytes(byte[] bytes) { + this.bytes = bytes; + return this; + } + + public Builder vendorParams(ObjectNode vendorParams) { + this.vendorParams = vendorParams; + return this; + } + + public Builder vendorParamsShape(ShapeId vendorParamsShape) { + this.vendorParamsShape = vendorParamsShape; + return this; + } + } +} diff --git a/smithy-protocol-test-traits/src/main/java/software/amazon/smithy/protocoltests/traits/eventstream/EventHeaderValue.java b/smithy-protocol-test-traits/src/main/java/software/amazon/smithy/protocoltests/traits/eventstream/EventHeaderValue.java new file mode 100644 index 00000000000..f1ccfea6acc --- /dev/null +++ b/smithy-protocol-test-traits/src/main/java/software/amazon/smithy/protocoltests/traits/eventstream/EventHeaderValue.java @@ -0,0 +1,373 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package software.amazon.smithy.protocoltests.traits.eventstream; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.format.DateTimeFormatter; +import java.util.Base64; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.logging.Logger; +import software.amazon.smithy.model.node.Node; +import software.amazon.smithy.model.node.ObjectNode; +import software.amazon.smithy.model.node.StringNode; +import software.amazon.smithy.model.node.ToNode; +import software.amazon.smithy.utils.SmithyBuilder; + +public abstract class EventHeaderValue implements ToNode { + private static final Logger LOGGER = Logger.getLogger(EventHeaderValue.class.getName()); + + protected final T value; + private final Type type; + + private EventHeaderValue(Type type, T value) { + this.type = type; + this.value = value; + } + + public Type getType() { + return type; + } + + public enum Type { + BOOLEAN, + BYTE, + SHORT, + INTEGER, + LONG, + BLOB, + STRING, + TIMESTAMP; + } + + public boolean asBoolean() { + throw new UnsupportedOperationException("Member boolean not supported for union of type: " + type); + } + + public byte asByte() { + throw new UnsupportedOperationException("Member byte not supported for union of type: " + type); + } + + public short asShort() { + throw new UnsupportedOperationException("Member short not supported for union of type: " + type); + } + + public int asInteger() { + throw new UnsupportedOperationException("Member int not supported for union of type: " + type); + } + + public long asLong() { + throw new UnsupportedOperationException("Member long not supported for union of type: " + type); + } + + public byte[] asBlob() { + throw new UnsupportedOperationException("Member blob not supported for union of type: " + type); + } + + public String asString() { + throw new UnsupportedOperationException("Member string not supported for union of type: " + type); + } + + public Instant asTimestamp() { + throw new UnsupportedOperationException("Member timestamp not supported for union of type: " + type); + } + + public T getValue() { + return value; + } + + @Override + public int hashCode() { + return Objects.hash(type, getValue()); + } + + @Override + public boolean equals(Object other) { + if (other == this) { + return true; + } + if (other == null || getClass() != other.getClass()) { + return false; + } + return Objects.equals(getValue(), ((EventHeaderValue) other).getValue()); + } + + public static Builder builder() { + return new Builder(); + } + + public static EventHeaderValue fromNode(Node node) { + ObjectNode objectNode = node.expectObjectNode(); + Builder builder = builder(); + + for (Map.Entry pair : objectNode.getMembers().entrySet()) { + Node value = pair.getValue(); + Type headerType = Type.valueOf(pair.getKey().getValue().toUpperCase(Locale.ENGLISH)); + switch (headerType) { + case BOOLEAN: + builder.setBoolean(value.expectBooleanNode().getValue()); + break; + case BYTE: + builder.setByte(value.expectNumberNode().getValue().byteValue()); + break; + case SHORT: + builder.setShort(value.expectNumberNode().getValue().shortValue()); + break; + case INTEGER: + builder.setInteger(value.expectNumberNode().getValue().intValue()); + break; + case LONG: + builder.setLong(value.expectNumberNode().getValue().longValue()); + break; + case BLOB: + builder.setBlob(Base64.getDecoder().decode(value.expectStringNode().getValue())); + break; + case STRING: + builder.setString(value.expectStringNode().getValue()); + break; + case TIMESTAMP: + if (value.isNumberNode()) { + builder.setTimestamp(value.expectNumberNode().getValue().longValue()); + } else { + builder.setTimestamp(value.expectStringNode().getValue()); + } + break; + default: + throw new IllegalArgumentException("Unexpected header value type: " + headerType); + } + } + + return builder.build(); + } + + public static final class BooleanMember extends EventHeaderValue { + public BooleanMember(boolean value) { + super(Type.BOOLEAN, value); + } + + @Override + public boolean asBoolean() { + return value; + } + + @Override + public Node toNode() { + return Node.objectNodeBuilder() + .withMember(getType().name().toLowerCase(Locale.ENGLISH), Node.from(value)) + .build(); + } + } + + public static final class ByteMember extends EventHeaderValue { + public ByteMember(byte value) { + super(Type.BYTE, value); + } + + @Override + public byte asByte() { + return value; + } + + @Override + public Node toNode() { + return Node.objectNodeBuilder() + .withMember(getType().name().toLowerCase(Locale.ENGLISH), Node.from(value)) + .build(); + } + } + + public static final class ShortMember extends EventHeaderValue { + public ShortMember(short value) { + super(Type.SHORT, value); + } + + @Override + public short asShort() { + return value; + } + + @Override + public Node toNode() { + return Node.objectNodeBuilder() + .withMember(getType().name().toLowerCase(Locale.ENGLISH), Node.from(value)) + .build(); + } + } + + public static final class IntegerMember extends EventHeaderValue { + public IntegerMember(int value) { + super(Type.INTEGER, value); + } + + @Override + public int asInteger() { + return value; + } + + @Override + public Node toNode() { + return Node.objectNodeBuilder() + .withMember(getType().name().toLowerCase(Locale.ENGLISH), Node.from(value)) + .build(); + } + } + + public static final class LongMember extends EventHeaderValue { + public LongMember(long value) { + super(Type.LONG, value); + } + + @Override + public long asLong() { + return value; + } + + @Override + public Node toNode() { + return Node.objectNodeBuilder() + .withMember(getType().name().toLowerCase(Locale.ENGLISH), Node.from(value)) + .build(); + } + } + + public static final class BlobMember extends EventHeaderValue { + public BlobMember(byte[] value) { + super(Type.BLOB, value); + } + + public BlobMember(String value) { + super(Type.BLOB, value.getBytes(StandardCharsets.UTF_8)); + } + + @Override + public byte[] asBlob() { + return value; + } + + @Override + public String asString() { + return Base64.getEncoder().encodeToString(value); + } + + @Override + public Node toNode() { + return Node.objectNodeBuilder() + .withMember(getType().name().toLowerCase(Locale.ENGLISH), Node.from(asString())) + .build(); + } + } + + public static final class StringMember extends EventHeaderValue { + public StringMember(String value) { + super(Type.STRING, value); + } + + @Override + public String asString() { + return value; + } + + @Override + public Node toNode() { + return Node.objectNodeBuilder() + .withMember(getType().name().toLowerCase(Locale.ENGLISH), Node.from(value)) + .build(); + } + } + + public static final class TimestampMember extends EventHeaderValue { + public TimestampMember(Instant value) { + super(Type.TIMESTAMP, value); + } + + public TimestampMember(String value) { + super(Type.TIMESTAMP, Instant.from(DateTimeFormatter.ISO_INSTANT.parse(value))); + } + + public TimestampMember(long value) { + super(Type.TIMESTAMP, Instant.ofEpochSecond(value)); + } + + @Override + public Instant asTimestamp() { + return value; + } + + @Override + public String asString() { + return DateTimeFormatter.ISO_INSTANT.format(value); + } + + @Override + public Node toNode() { + return Node.objectNodeBuilder() + .withMember(getType().name().toLowerCase(Locale.ENGLISH), Node.from(asString())) + .build(); + } + } + + public static final class Builder implements SmithyBuilder> { + private EventHeaderValue value; + + @Override + public EventHeaderValue build() { + return Objects.requireNonNull(value); + } + + public Builder setBoolean(boolean value) { + return setValue(new BooleanMember(value)); + } + + public Builder setByte(byte value) { + return setValue(new ByteMember(value)); + } + + public Builder setShort(short value) { + return setValue(new ShortMember(value)); + } + + public Builder setInteger(int value) { + return setValue(new IntegerMember(value)); + } + + public Builder setLong(long value) { + return setValue(new LongMember(value)); + } + + public Builder setBlob(byte[] value) { + return setValue(new BlobMember(value)); + } + + public Builder setBlob(String value) { + return setValue(new BlobMember(value)); + } + + public Builder setString(String value) { + return setValue(new StringMember(value)); + } + + public Builder setTimestamp(Instant value) { + return setValue(new TimestampMember(value)); + } + + public Builder setTimestamp(String value) { + return setValue(new TimestampMember(value)); + } + + public Builder setTimestamp(long value) { + return setValue(new TimestampMember(value)); + } + + private Builder setValue(EventHeaderValue value) { + if (this.value != null) { + throw new IllegalArgumentException("Only one value may be set for unions."); + } + this.value = value; + return this; + } + } +} diff --git a/smithy-protocol-test-traits/src/main/java/software/amazon/smithy/protocoltests/traits/eventstream/EventStreamTestCase.java b/smithy-protocol-test-traits/src/main/java/software/amazon/smithy/protocoltests/traits/eventstream/EventStreamTestCase.java new file mode 100644 index 00000000000..2df9266f9e8 --- /dev/null +++ b/smithy-protocol-test-traits/src/main/java/software/amazon/smithy/protocoltests/traits/eventstream/EventStreamTestCase.java @@ -0,0 +1,298 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package software.amazon.smithy.protocoltests.traits.eventstream; + +import java.util.List; +import java.util.Optional; +import software.amazon.smithy.model.node.ObjectNode; +import software.amazon.smithy.model.shapes.ShapeId; +import software.amazon.smithy.protocoltests.traits.AppliesTo; +import software.amazon.smithy.protocoltests.traits.TestExpectation; +import software.amazon.smithy.utils.BuilderRef; +import software.amazon.smithy.utils.SmithyBuilder; +import software.amazon.smithy.utils.ToSmithyBuilder; + +/** + * A single event stream test case. + */ +public final class EventStreamTestCase implements ToSmithyBuilder { + private final String id; + private final ShapeId protocol; + private final ObjectNode initialRequestParams; + private final ObjectNode initialRequest; + private final ShapeId initialRequestShape; + private final ObjectNode initialResponseParams; + private final ObjectNode initialResponse; + private final ShapeId initialResponseShape; + private final List events; + private final TestExpectation expectation; + private final ObjectNode vendorParams; + private final ShapeId vendorParamsShape; + private final String documentation; + private final AppliesTo appliesTo; + + private EventStreamTestCase(Builder builder) { + this.id = SmithyBuilder.requiredState("id", builder.id); + this.protocol = SmithyBuilder.requiredState("protocol", builder.protocol); + this.initialRequestParams = builder.initialRequestParams; + this.initialRequest = builder.initialRequest; + this.initialRequestShape = builder.initialRequestShape; + this.initialResponseParams = builder.initialResponseParams; + this.initialResponse = builder.initialResponse; + this.initialResponseShape = builder.initialResponseShape; + this.events = builder.events.copy(); + this.expectation = builder.expectation; + this.vendorParams = builder.vendorParams; + this.vendorParamsShape = builder.vendorParamsShape; + this.documentation = builder.documentation; + this.appliesTo = builder.appliesTo; + } + + /** + * Get the test case identifier. + * + *

This identifier can be used by protocol test implementations to filter out + * unsupported test cases by ID, to generate test case names, etc. No two test cases + * can share the same ID. + * + * @return Returns the test case identifier. + */ + public String getId() { + return id; + } + + /** + * @return Returns the protocol the test case applies to. + */ + public ShapeId getProtocol() { + return protocol; + } + + /** + * @return Returns the initial request's parameters as an ObjectNode. + */ + public Optional getInitialRequestParams() { + return Optional.ofNullable(initialRequestParams); + } + + /** + * @return Returns the initial request as an ObjectNode. + */ + public Optional getInitialRequest() { + return Optional.ofNullable(initialRequest); + } + + /** + * @return Returns a shape describing the structure of the initial request. + */ + public Optional getInitialRequestShape() { + return Optional.ofNullable(initialRequestShape); + } + + /** + * @return Returns the initial response's parameters as an ObjectNode. + */ + public Optional getInitialResponseParams() { + return Optional.ofNullable(initialResponseParams); + } + + /** + * @return Returns the initial response as an ObjectNode. + */ + public Optional getInitialResponse() { + return Optional.ofNullable(initialResponse); + } + + /** + * @return Returns a shape describing the structure of the initial response. + */ + public Optional getInitialResponseShape() { + return Optional.ofNullable(initialResponseShape); + } + + /** + * Gets the list of events under test. + * + *

Each event must be sent in the order presented. Implementations MAY send + * events concurrently. + * + * @return Returns the list of events under test. + */ + public List getEvents() { + return events; + } + + /** + * @return Returns the expected result of the test case. + */ + public TestExpectation getExpectation() { + return expectation; + } + + /** + * Gets any additional vendor-specific parameters. + * + *

This could include credentials, endpoint configuration, or + * anything else that may impact the protocol's serialization of events. + * + * @return Returns vendor-specific parameters as an ObjectNode. + */ + public Optional getVendorParams() { + return Optional.ofNullable(vendorParams); + } + + /** + * @return Returns a shape describing the structure of the vendor params. + */ + public Optional getVendorParamsShape() { + return Optional.ofNullable(vendorParamsShape); + } + + /** + * @return Returns documentation to write out at the beginning of the test case. + */ + public Optional getDocumentation() { + return Optional.ofNullable(documentation); + } + + /** + * @return Returns what sort of implementation the test case applies to. + */ + public Optional getAppliesTo() { + return Optional.ofNullable(appliesTo); + } + + /** + * @return Returns the test case as a builder + */ + @Override + public Builder toBuilder() { + return builder() + .id(id) + .protocol(protocol) + .initialRequestParams(initialRequestParams) + .initialRequest(initialRequest) + .initialRequestShape(initialRequestShape) + .initialResponseParams(initialResponseParams) + .initialResponse(initialResponse) + .initialResponseShape(initialResponseShape) + .events(events) + .expectation(expectation) + .vendorParams(vendorParams) + .vendorParamsShape(vendorParamsShape) + .documentation(documentation) + .appliesTo(appliesTo); + } + + /** + * Creates a builder for an EventStreamTestCase. + * + * @return Returns a newly-created builder. + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Builder used to create {@link EventStreamTestCase}. + */ + public static final class Builder implements SmithyBuilder { + private String id; + private ShapeId protocol; + private ObjectNode initialRequestParams; + private ObjectNode initialRequest; + private ShapeId initialRequestShape; + private ObjectNode initialResponseParams; + private ObjectNode initialResponse; + private ShapeId initialResponseShape; + private final BuilderRef> events = BuilderRef.forList(); + private TestExpectation expectation = TestExpectation.success(); + private ObjectNode vendorParams; + private ShapeId vendorParamsShape; + private String documentation; + private AppliesTo appliesTo; + + @Override + public EventStreamTestCase build() { + return new EventStreamTestCase(this); + } + + public Builder id(String id) { + this.id = id; + return this; + } + + public Builder protocol(ShapeId protocol) { + this.protocol = protocol; + return this; + } + + public Builder initialRequestParams(ObjectNode initialRequestParams) { + this.initialRequestParams = initialRequestParams; + return this; + } + + public Builder initialRequest(ObjectNode initialRequest) { + this.initialRequest = initialRequest; + return this; + } + + public Builder initialRequestShape(ShapeId initialRequestShape) { + this.initialRequestShape = initialRequestShape; + return this; + } + + public Builder initialResponseParams(ObjectNode initialResponseParams) { + this.initialResponseParams = initialResponseParams; + return this; + } + + public Builder initialResponse(ObjectNode initialResponse) { + this.initialResponse = initialResponse; + return this; + } + + public Builder initialResponseShape(ShapeId initialResponseShape) { + this.initialResponseShape = initialResponseShape; + return this; + } + + public Builder events(List events) { + this.events.clear(); + this.events.get().addAll(events); + return this; + } + + public Builder event(Event event) { + this.events.get().add(event); + return this; + } + + public Builder expectation(TestExpectation expectation) { + this.expectation = expectation; + return this; + } + + public Builder vendorParams(ObjectNode vendorParams) { + this.vendorParams = vendorParams; + return this; + } + + public Builder vendorParamsShape(ShapeId vendorParamsShape) { + this.vendorParamsShape = vendorParamsShape; + return this; + } + + public Builder documentation(String documentation) { + this.documentation = documentation; + return this; + } + + public Builder appliesTo(AppliesTo appliesTo) { + this.appliesTo = appliesTo; + return this; + } + } +} diff --git a/smithy-protocol-test-traits/src/main/java/software/amazon/smithy/protocoltests/traits/eventstream/EventStreamTestsTrait.java b/smithy-protocol-test-traits/src/main/java/software/amazon/smithy/protocoltests/traits/eventstream/EventStreamTestsTrait.java new file mode 100644 index 00000000000..3b8410c44ac --- /dev/null +++ b/smithy-protocol-test-traits/src/main/java/software/amazon/smithy/protocoltests/traits/eventstream/EventStreamTestsTrait.java @@ -0,0 +1,87 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package software.amazon.smithy.protocoltests.traits.eventstream; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; +import software.amazon.smithy.model.SourceLocation; +import software.amazon.smithy.model.node.ArrayNode; +import software.amazon.smithy.model.node.Node; +import software.amazon.smithy.model.node.NodeMapper; +import software.amazon.smithy.model.shapes.ShapeId; +import software.amazon.smithy.model.traits.AbstractTrait; +import software.amazon.smithy.model.traits.Trait; +import software.amazon.smithy.protocoltests.traits.AppliesTo; + +/** + * Defines a list of protocol tests that enforce how an event stream + * is serialized / deserialized for a specific protocol. + */ +public final class EventStreamTestsTrait extends AbstractTrait { + public static final ShapeId ID = ShapeId.from("smithy.test#eventStreamTests"); + + private final List testCases; + + public EventStreamTestsTrait(List testCases) { + this(SourceLocation.NONE, testCases); + } + + public EventStreamTestsTrait(SourceLocation sourceLocation, List testCases) { + super(ID, sourceLocation); + this.testCases = testCases; + } + + /** + * @return Returns all test cases. + */ + public List getTestCases() { + return testCases; + } + + /** + * Gets all test cases that apply to a client or server. + * + *

Test cases that define an {@code appliesTo} member are tests that + * should only be implemented by clients or servers. It is assumed that + * test cases that do not define an {@code appliesTo} member are + * implemented by both client and server implementations. + * + * @param appliesTo The type of test case to retrieve. + * @return Returns the matching test cases. + */ + public List getTestCasesFor(AppliesTo appliesTo) { + return testCases.stream() + .filter(test -> !test.getAppliesTo().filter(value -> value != appliesTo).isPresent()) + .collect(Collectors.toList()); + } + + @Override + protected Node createNode() { + NodeMapper mapper = new NodeMapper(); + mapper.disableToNodeForClass(EventStreamTestsTrait.class); + mapper.setOmitEmptyValues(true); + return mapper.serialize(this); + } + + public static final class Provider extends AbstractTrait.Provider { + public Provider() { + super(ID); + } + + @Override + public Trait createTrait(ShapeId target, Node value) { + ArrayNode values = value.expectArrayNode(); + NodeMapper mapper = new NodeMapper(); + List cases = new ArrayList<>(values.size()); + for (Node testCase : values) { + cases.add(mapper.deserialize(testCase, EventStreamTestCase.class)); + } + EventStreamTestsTrait result = new EventStreamTestsTrait(cases); + result.setNodeCache(value); + return result; + } + } +} diff --git a/smithy-protocol-test-traits/src/main/java/software/amazon/smithy/protocoltests/traits/eventstream/EventStreamTestsTraitValidator.java b/smithy-protocol-test-traits/src/main/java/software/amazon/smithy/protocoltests/traits/eventstream/EventStreamTestsTraitValidator.java new file mode 100644 index 00000000000..dd06d72cf28 --- /dev/null +++ b/smithy-protocol-test-traits/src/main/java/software/amazon/smithy/protocoltests/traits/eventstream/EventStreamTestsTraitValidator.java @@ -0,0 +1,245 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package software.amazon.smithy.protocoltests.traits.eventstream; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.knowledge.EventStreamIndex; +import software.amazon.smithy.model.knowledge.EventStreamInfo; +import software.amazon.smithy.model.node.Node; +import software.amazon.smithy.model.node.ObjectNode; +import software.amazon.smithy.model.shapes.OperationShape; +import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.model.traits.Trait; +import software.amazon.smithy.model.validation.AbstractValidator; +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.protocoltests.traits.ProtocolTestValidationUtils; +import software.amazon.smithy.utils.ListUtils; + +/** + * Validates event stream test cases. It performs the following validations for each test case: + * + *

    + *
  • Validates that the initial request params match the modeled initial request.
  • + *
  • Validates that the initial request matches the initial request shape if set.
  • + *
  • Validates that the initial response params match the modeled initial response.
  • + *
  • Validates that the initial response matches the initial response shape if set.
  • + *
  • Validates that the vendor params match the vendor params shape if set.
  • + *
  • Validates that there is at least one event or initial message defined.
  • + *
  • Validates that the ID is unique.
  • + *
+ * + * For each event in the test case, the following validations are performed: + * + *
    + *
  • If the event is a REQUEST event and it has params, validates that the operation has an input stream.
  • + *
  • If the event is a RESPONSE event and it has params, validates that the operation has an output stream.
  • + *
  • If the event has params, validates that it matches an event in the event stream.
  • + *
  • If the body media type is set to XML or JSON, validate that the body parseable.
  • \ + *
  • Validates that the vendor params match the vendor params shape if set.
  • + *
+ */ +public final class EventStreamTestsTraitValidator extends AbstractValidator { + @Override + public List validate(Model model) { + List events = new ArrayList<>(); + for (OperationShape shape : model.getOperationShapesWithTrait(EventStreamTestsTrait.class)) { + EventStreamTestsTrait trait = shape.expectTrait(EventStreamTestsTrait.class); + List cases = trait.getTestCases(); + for (int i = 0; i < cases.size(); i++) { + EventStreamTestCase testCase = cases.get(i); + events.addAll(validateTestCase(model, shape, trait, testCase, i)); + } + } + return events; + } + + private List validateTestCase( + Model model, + OperationShape operation, + EventStreamTestsTrait trait, + EventStreamTestCase testCase, + int testCaseIndex + ) { + + EventStreamIndex eventStreamIndex = EventStreamIndex.of(model); + Optional inputStream = eventStreamIndex.getInputInfo(operation); + Optional outputStream = eventStreamIndex.getOutputInfo(operation); + + List validationEvents = new ArrayList<>(); + if (testCase.getInitialRequestParams().isPresent()) { + NodeValidationVisitor inputValidator = createVisitor( + testCase.getInitialRequestParams().get(), + model, + operation, + testCaseIndex + ".initialRequestParams"); + validationEvents.addAll(model.expectShape(operation.getInputShape()).accept(inputValidator)); + } + + if (testCase.getInitialRequestShape().isPresent()) { + NodeValidationVisitor initialRequestValidator = createVisitor( + testCase.getInitialRequest().orElseGet(Node::objectNode), + model, + operation, + testCaseIndex + ".initialRequest"); + validationEvents.addAll(model.expectShape(testCase.getInitialRequestShape().get()) + .accept(initialRequestValidator)); + } + + if (testCase.getInitialResponseParams().isPresent()) { + NodeValidationVisitor outputValidator = createVisitor( + testCase.getInitialResponseParams().get(), + model, + operation, + testCaseIndex + ".initialResponseParams"); + validationEvents.addAll(model.expectShape(operation.getOutputShape()).accept(outputValidator)); + } + + if (testCase.getInitialResponseShape().isPresent()) { + NodeValidationVisitor initialResponseValidator = createVisitor( + testCase.getInitialResponse().orElseGet(Node::objectNode), + model, + operation, + testCaseIndex + ".initialResponse"); + validationEvents.addAll(model.expectShape(testCase.getInitialResponseShape().get()) + .accept(initialResponseValidator)); + } + + if (testCase.getVendorParamsShape().isPresent()) { + NodeValidationVisitor vendorParamsValidator = createVisitor( + testCase.getVendorParams().orElseGet(Node::objectNode), + model, + operation, + testCaseIndex + ".vendorParams"); + validationEvents.addAll(model.expectShape(testCase.getVendorParamsShape().get()) + .accept(vendorParamsValidator)); + } + + List events = testCase.getEvents(); + for (int i = 0; i < events.size(); i++) { + Event event = events.get(i); + String eventContextSuffix = String.format("%s.events.%s", testCaseIndex, i); + + if (event.getParams().isPresent()) { + String paramsContextSuffix = eventContextSuffix + ".params"; + NodeValidationVisitor paramsValidator = createVisitor( + event.getParams().get(), + model, + operation, + paramsContextSuffix); + + if (event.getType().equals(EventType.REQUEST)) { + if (!inputStream.isPresent()) { + String message = String.format( + "%s.%s: Invalid request event for operation %s that has no input stream.", + EventStreamTestsTrait.ID, + eventContextSuffix, + operation.getId()); + validationEvents.add(error(operation, trait, message)); + } else { + validationEvents.addAll(inputStream.get().getEventStreamTarget().accept(paramsValidator)); + } + } else if (event.getType().equals(EventType.RESPONSE)) { + if (!outputStream.isPresent()) { + String message = String.format( + "%s.%s: Invalid response event for operation %s that has no output stream.", + EventStreamTestsTrait.ID, + eventContextSuffix, + operation.getId()); + validationEvents.add(error(operation, trait, message, eventContextSuffix)); + } else { + validationEvents.addAll(outputStream.get().getEventStreamTarget().accept(paramsValidator)); + } + } + } + + if (event.getBodyMediaType().isPresent()) { + validationEvents.addAll(validateMediaType(operation, trait, event, eventContextSuffix)); + } + + if (event.getVendorParamsShape().isPresent()) { + Shape vendorParamsShape = model.expectShape(event.getVendorParamsShape().get()); + String vendorParamsContextSuffix = String.format("%s.events.%s.vendorParams", testCaseIndex, i); + NodeValidationVisitor vendorParamsValidator = createVisitor( + event.getVendorParams().orElseGet(Node::objectNode), + model, + operation, + vendorParamsContextSuffix); + validationEvents.addAll(vendorParamsShape.accept(vendorParamsValidator)); + } + } + + if (testCase.getEvents().isEmpty() + && !testCase.getInitialRequestParams().isPresent() + && !testCase.getInitialRequest().isPresent() + && !testCase.getInitialResponseParams().isPresent() + && !testCase.getInitialResponse().isPresent()) { + validationEvents.add(error( + operation, + trait, + String.format( + "%s.%s: At least one event, an initial request, or an initial response must be set.", + EventStreamTestsTrait.ID, + testCaseIndex), + String.valueOf(testCaseIndex))); + } + + return validationEvents; + } + + private List validateMediaType( + OperationShape operation, + Trait trait, + Event event, + String eventContextSuffix + ) { + if (!event.getBodyMediaType().isPresent()) { + return Collections.emptyList(); + } + + return ProtocolTestValidationUtils.validateMediaType(event.getBody().orElse(""), event.getBodyMediaType().get()) + .map(e -> ListUtils.of(emitMediaTypeError(operation, trait, eventContextSuffix, event, e))) + .orElse(Collections.emptyList()); + } + + private ValidationEvent emitMediaTypeError( + OperationShape operation, + Trait trait, + String eventContextSuffix, + Event test, + Throwable e + ) { + return danger(operation, + trait, + String.format( + "%s.%s.body: Invalid %s content: %s", + EventStreamTestsTrait.ID, + eventContextSuffix, + test.getBodyMediaType().orElse(""), + e.getMessage())); + } + + private NodeValidationVisitor createVisitor( + ObjectNode value, + Model model, + Shape shape, + String contextSuffix + ) { + return NodeValidationVisitor.builder() + .model(model) + .eventShapeId(shape.getId()) + .value(value) + .startingContext(EventStreamTestsTrait.ID + "." + contextSuffix) + .eventId(getName()) + .timestampValidationStrategy(TimestampValidationStrategy.FORMAT) + .addFeature(NodeValidationVisitor.Feature.ALLOW_OPTIONAL_NULLS) + .build(); + } +} diff --git a/smithy-protocol-test-traits/src/main/java/software/amazon/smithy/protocoltests/traits/eventstream/EventType.java b/smithy-protocol-test-traits/src/main/java/software/amazon/smithy/protocoltests/traits/eventstream/EventType.java new file mode 100644 index 00000000000..b2463475db4 --- /dev/null +++ b/smithy-protocol-test-traits/src/main/java/software/amazon/smithy/protocoltests/traits/eventstream/EventType.java @@ -0,0 +1,39 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package software.amazon.smithy.protocoltests.traits.eventstream; + +import java.util.Locale; +import software.amazon.smithy.model.node.Node; +import software.amazon.smithy.model.node.ToNode; + +/** + * The different types of event that are able to be sent over event streams. + */ +public enum EventType implements ToNode { + /** + * Indicates the event is a request message. + */ + REQUEST, + + /** + * Indicates the event is a response message. + */ + RESPONSE; + + @Override + public String toString() { + return super.toString().toLowerCase(Locale.ENGLISH); + } + + public static EventType fromNode(Node node) { + return EventType.valueOf( + node.expectStringNode().expectOneOf("request", "response").toUpperCase(Locale.ENGLISH)); + } + + @Override + public Node toNode() { + return Node.from(toString()); + } +} diff --git a/smithy-protocol-test-traits/src/main/resources/META-INF/services/software.amazon.smithy.model.traits.TraitService b/smithy-protocol-test-traits/src/main/resources/META-INF/services/software.amazon.smithy.model.traits.TraitService index 685eaf6e300..84580b4aad8 100644 --- a/smithy-protocol-test-traits/src/main/resources/META-INF/services/software.amazon.smithy.model.traits.TraitService +++ b/smithy-protocol-test-traits/src/main/resources/META-INF/services/software.amazon.smithy.model.traits.TraitService @@ -1,3 +1,4 @@ software.amazon.smithy.protocoltests.traits.HttpMalformedRequestTestsTrait$Provider software.amazon.smithy.protocoltests.traits.HttpRequestTestsTrait$Provider software.amazon.smithy.protocoltests.traits.HttpResponseTestsTrait$Provider +software.amazon.smithy.protocoltests.traits.eventstream.EventStreamTestsTrait$Provider diff --git a/smithy-protocol-test-traits/src/main/resources/META-INF/services/software.amazon.smithy.model.validation.Validator b/smithy-protocol-test-traits/src/main/resources/META-INF/services/software.amazon.smithy.model.validation.Validator index ebe60eee489..91504507f11 100644 --- a/smithy-protocol-test-traits/src/main/resources/META-INF/services/software.amazon.smithy.model.validation.Validator +++ b/smithy-protocol-test-traits/src/main/resources/META-INF/services/software.amazon.smithy.model.validation.Validator @@ -3,3 +3,4 @@ software.amazon.smithy.protocoltests.traits.HttpResponseTestsOutputValidator software.amazon.smithy.protocoltests.traits.HttpResponseTestsErrorValidator software.amazon.smithy.protocoltests.traits.UniqueProtocolTestCaseIdValidator software.amazon.smithy.protocoltests.traits.HttpMalformedRequestTestsValidator +software.amazon.smithy.protocoltests.traits.eventstream.EventStreamTestsTraitValidator diff --git a/smithy-protocol-test-traits/src/main/resources/META-INF/smithy/smithy.test.smithy b/smithy-protocol-test-traits/src/main/resources/META-INF/smithy/smithy.test.smithy index 185cdb4b146..decbfa7f115 100644 --- a/smithy-protocol-test-traits/src/main/resources/META-INF/smithy/smithy.test.smithy +++ b/smithy-protocol-test-traits/src/main/resources/META-INF/smithy/smithy.test.smithy @@ -7,11 +7,11 @@ namespace smithy.test @trait(selector: "operation") @length(min: 1) list httpRequestTests { - member: HttpRequestTestCase, + member: HttpRequestTestCase } @private -structure HttpRequestTestCase { +structure HttpRequestTestCase with [HttpRequestMixin] { /// The identifier of the test case. This identifier can be used by /// protocol test implementations to filter out unsupported test /// cases by ID, to generate test case names, etc. The provided `id` @@ -19,108 +19,23 @@ structure HttpRequestTestCase { /// test cases can share the same ID. @required @pattern("^[A-Za-z_][A-Za-z0-9_]+$") - id: String, + id: String /// The name of the protocol to test. @required @idRef(selector: "[trait|protocolDefinition]", failWhenMissing: true) - protocol: String, - - /// The expected serialized HTTP request method. - @required - @length(min: 1) - method: String, - - /// The request-target of the HTTP request, not including - /// the query string (for example, "/foo/bar"). - @required - @length(min: 1) - uri: String, - - /// The host / endpoint provided to the client, not including the path - /// or scheme (for example, "example.com"). - host: String, - - /// The host / endpoint that the client should send to, not including - /// the path or scheme (for example, "prefix.example.com"). - /// - /// This can differ from the host provided to the client if the `hostPrefix` - /// member of the `endpoint` trait is set, for instance. - resolvedHost: String, + protocol: String /// The optional authentication scheme shape ID to assume. It's /// possible that specific authentication schemes might influence /// the serialization logic of an HTTP request. @idRef(selector: "[trait|authDefinition]", failWhenMissing: true) - authScheme: String, - - /// A list of the expected serialized query string parameters. - /// - /// Each element in the list is a query string key value pair - /// that starts with the query string parameter name optionally - /// followed by "=", optionally followed by the query string - /// parameter value. For example, "foo=bar", "foo=", and "foo" - /// are all valid values. The query string parameter name and - /// the value MUST appear in the format in which it is expected - /// to be sent over the wire; if a key or value needs to be - /// percent-encoded, then it MUST appear percent-encoded in this list. - /// - /// A serialized HTTP request is not in compliance with the protocol - /// if any query string parameter defined in `queryParams` is not - /// defined in the request or if the value of a query string parameter - /// in the request differs from the expected value. - /// - /// `queryParams` applies no constraints on additional query parameters. - queryParams: StringList, - - /// A list of query string parameter names that must not appear in the - /// serialized HTTP request. - /// - /// Each value MUST appear in the format in which it is sent over the - /// wire; if a key needs to be percent-encoded, then it MUST appear - /// percent-encoded in this list. - forbidQueryParams: StringList, - - /// A list of query string parameter names that MUST appear in the - /// serialized request URI, but no assertion is made on the value. - /// - /// Each value MUST appear in the format in which it is sent over the - /// wire; if a key needs to be percent-encoded, then it MUST appear - /// percent-encoded in this list. - requireQueryParams: StringList, - - /// Defines a map of expected HTTP headers. - /// - /// Headers that are not listed in this map are ignored unless they are - /// explicitly forbidden through `forbidHeaders`. - headers: StringMap, - - /// A list of header field names that must not appear in the serialized - /// HTTP request. - forbidHeaders: StringList, - - /// A list of header field names that must appear in the serialized - /// HTTP message, but no assertion is made on the value. - /// - /// Headers listed in `headers` do not need to appear in this list. - requireHeaders: StringList, - - /// The expected HTTP message body. - /// - /// If no request body is defined, then no assertions are made about - /// the body of the message. - body: String, - - /// The media type of the `body`. - /// - /// This is used to help test runners to parse and validate the expected - /// data against generated data. - bodyMediaType: String, + authScheme: String /// Defines the input parameters used to generated the HTTP request. /// /// These parameters MUST be compatible with the input of the operation. - params: Document, + params: Document /// Defines vendor-specific parameters that are used to influence the /// request. For example, some vendors might utilize environment @@ -129,37 +44,37 @@ structure HttpRequestTestCase { /// /// If a `vendorParamsShape` is set, these parameters MUST be compatible /// with that shape's definition. - vendorParams: Document, + vendorParams: Document /// A shape to be used to validate the `vendorParams` member contents. /// /// If set, the parameters in `vendorParams` MUST be compatible with this /// shape's definition. @idRef(failWhenMissing: true) - vendorParamsShape: String, + vendorParamsShape: String /// A description of the test and what is being asserted. - documentation: String, + documentation: String /// Applies a list of tags to the test. - tags: NonEmptyStringList, + tags: NonEmptyStringList /// Indicates that the test case is only to be implemented by "client" or /// "server" implementations. This property is useful for identifying and /// testing edge cases of clients and servers that are impossible or /// undesirable to test in *both* client and server implementations. - appliesTo: AppliesTo, + appliesTo: AppliesTo } @private map StringMap { - key: String, - value: String, + key: String + value: String } @private list StringList { - member: String, + member: String } /// Define how an HTTP response is serialized given a specific protocol, @@ -167,11 +82,11 @@ list StringList { @trait(selector: ":test(operation, structure[trait|error])") @length(min: 1) list httpResponseTests { - member: HttpResponseTestCase, + member: HttpResponseTestCase } @private -structure HttpResponseTestCase { +structure HttpResponseTestCase with [HttpResponseMixin] { /// The identifier of the test case. This identifier can be used by /// protocol test implementations to filter out unsupported test /// cases by ID, to generate test case names, etc. The provided `id` @@ -179,59 +94,23 @@ structure HttpResponseTestCase { /// test cases can share the same ID. @required @pattern("^[A-Za-z_][A-Za-z0-9_]+$") - id: String, + id: String /// The shape ID of the protocol to test. @required @idRef(selector: "[trait|protocolDefinition]", failWhenMissing: true) - protocol: String, - - /// Defines the HTTP response code. - @required - @range(min: 100, max: 599) - code: Integer, + protocol: String /// The optional authentication scheme shape ID to assume. It's possible /// that specific authentication schemes might influence the serialization /// logic of an HTTP response. @idRef(selector: "[trait|authDefinition]", failWhenMissing: true) - authScheme: String, - - /// A map of expected HTTP headers. Each key represents a header field - /// name and each value represents the expected header value. An HTTP - /// response is not in compliance with the protocol if any listed header - /// is missing from the serialized response or if the expected header - /// value differs from the serialized response value. - /// - /// `headers` applies no constraints on additional headers. - headers: StringMap, - - /// A list of header field names that must not appear. - forbidHeaders: StringList, - - /// A list of header field names that must appear in the serialized - /// HTTP message, but no assertion is made on the value. - /// - /// Headers listed in `headers` map do not need to appear in this list. - requireHeaders: StringList, - - /// Defines the HTTP message body. - /// - /// If no response body is defined, then no assertions are made about - /// the body of the message. - body: String, - - /// The media type of the `body`. - /// - /// This is used to help test runners to parse and validate the expected - /// data against generated data. Binary media type formats require that - /// the contents of `body` are base64 encoded. - bodyMediaType: String, + authScheme: String /// Defines the output parameters deserialized from the HTTP response. /// /// These parameters MUST be compatible with the output of the operation. - params: Document, + params: Document /// Defines vendor-specific parameters that are used to influence the /// response. For example, some vendors might utilize environment @@ -240,31 +119,31 @@ structure HttpResponseTestCase { /// /// If a `vendorParamsShape` is set, these parameters MUST be compatible /// with that shape's definition. - vendorParams: Document, + vendorParams: Document /// A shape to be used to validate the `vendorParams` member contents. /// /// If set, the parameters in `vendorParams` MUST be compatible with this /// shape's definition. @idRef(failWhenMissing: true) - vendorParamsShape: String, + vendorParamsShape: String /// A description of the test and what is being asserted. - documentation: String, + documentation: String /// Applies a list of tags to the test. - tags: NonEmptyStringList, + tags: NonEmptyStringList /// Indicates that the test case is only to be implemented by "client" or /// "server" implementations. This property is useful for identifying and /// testing edge cases of clients and servers that are impossible or /// undesirable to test in *both* client and server implementations. - appliesTo: AppliesTo, + appliesTo: AppliesTo } @private list NonEmptyStringList { - member: NonEmptyString, + member: NonEmptyString } @private @@ -299,48 +178,47 @@ structure HttpMalformedRequestTestCase { /// test cases can share the same ID. @required @pattern("^[A-Za-z_][A-Za-z0-9_]+$") - id: String, + id: String /// The name of the protocol to test. @required @idRef(selector: "[trait|protocolDefinition]", failWhenMissing: true) - protocol: String, + protocol: String /// The malformed request to send. @required - request: HttpMalformedRequestDefinition, + request: HttpMalformedRequestDefinition /// The expected response. @required - response: HttpMalformedResponseDefinition, + response: HttpMalformedResponseDefinition /// A description of the test and what is being asserted. - documentation: String, + documentation: String /// Applies a list of tags to the test. - tags: NonEmptyStringList, + tags: NonEmptyStringList /// An optional set of test parameters for parameterized testing. - testParameters: HttpMalformedRequestTestParametersDefinition, + testParameters: HttpMalformedRequestTestParametersDefinition } @private structure HttpMalformedRequestDefinition { - /// The HTTP request method. @required @length(min: 1) - method: String, + method: String /// The request-target of the HTTP request, not including /// the query string (for example, "/foo/bar"). @required @length(min: 1) - uri: String, + uri: String /// The host / endpoint provided to the client, not including the path /// or scheme (for example, "example.com"). - host: String, + host: String /// A list of the serialized query string parameters to include in the request. /// @@ -352,13 +230,13 @@ structure HttpMalformedRequestDefinition { /// the value MUST appear in the format in which it is expected /// to be sent over the wire; if a key or value needs to be /// percent-encoded, then it MUST appear percent-encoded in this list. - queryParams: StringList, + queryParams: StringList /// Defines a map of HTTP headers to include in the request - headers: StringMap, + headers: StringMap /// The HTTP message body to include in the request - body: String, + body: String /// The media type of the `body`. /// @@ -369,26 +247,25 @@ structure HttpMalformedRequestDefinition { @private structure HttpMalformedResponseDefinition { - /// Defines a map of expected HTTP headers. /// /// Headers that are not listed in this map are ignored. - headers: StringMap, + headers: StringMap /// Defines the HTTP response code. @required @range(min: 100, max: 599) - code: Integer, + code: Integer /// The expected response body. - body: HttpMalformedResponseBodyDefinition, + body: HttpMalformedResponseBodyDefinition } @private structure HttpMalformedResponseBodyDefinition { /// The assertion to execute against the response body. @required - assertion: HttpMalformedResponseBodyAssertion, + assertion: HttpMalformedResponseBodyAssertion /// The media type of the response body. /// @@ -402,7 +279,7 @@ structure HttpMalformedResponseBodyDefinition { union HttpMalformedResponseBodyAssertion { /// Defines the expected serialized response body, which will be matched /// exactly. - contents: String, + contents: String /// A regex to evaluate against the `message` field in the body. For /// responses that may have some variance from platform to platform, @@ -412,6 +289,360 @@ union HttpMalformedResponseBodyAssertion { @private map HttpMalformedRequestTestParametersDefinition { - key: String, + key: String value: StringList } + +/// Defines a list of protocol tests that enforce how an event stream +/// is serialized / deserialized for a specific protocol. +@trait(selector: "operation :test(-[input, output]-> structure > member > union[trait|streaming])") +@length(min: 1) +list eventStreamTests { + member: EventStreamTestCase +} + +/// A single event stream test case. +@private +structure EventStreamTestCase { + /// The identifier of the test case. This identifier can be used by + /// protocol test implementations to filter out unsupported test + /// cases by ID, to generate test case names, etc. The provided `id` + /// MUST match Smithy's `identifier` ABNF. No two test cases can share + /// the same ID. + @required + @pattern("^[A-Za-z_][A-Za-z0-9_]+$") + id: String + + /// The protocol to test. + @required + @idRef(selector: "[trait|protocolDefinition]", failWhenMissing: true) + protocol: String + + /// The input parameters used to generate the initial request. + /// + /// These parameters MUST be compatible with the input shape of the operation. + initialRequestParams: Document + + /// The protocol-specific initial request. + /// + /// If an `initialRequestShape` is set, this value MUST be compatible with that + /// shape's definition. + initialRequest: Document + + /// A shape to be used to validate the `initialRequest` member's contents. + /// + /// If set, the value in `initialRequest` MUST be compatible with this shape's + /// definition. + /// + /// For HTTP protocols, use `smithy.test#InitialHttpRequest` + @idRef(selector: "structure", failWhenMissing: true) + initialRequestShape: String + + /// The output parameters used to generate the initial response. + /// + /// These parameters MUST be compatible with the output shape of the operation. + initialResponseParams: Document + + /// The protocol-specific initial response. + /// + /// If an `initialResponseShape` is set, this value MUST be compatible with that + /// shape's definition. + initialResponse: Document + + /// A shape to be used to validate the `initialResponse` member's contents. + /// + /// If set, the value in `initialResponse` MUST be compatible with this shape's + /// definition. + /// + /// For HTTP protocols, use `smithy.test#InitialHttpResponse` + @idRef(selector: "structure", failWhenMissing: true) + initialResponseShape: String + + /// A list of events to be sent over the event stream. This includes input message, + /// output message, and error events. + events: Events + + /// The kind of result that is expected from the event stream. + /// + /// If not set, the result is expected to be success. + expectation: TestExpectation + + /// Defines vendor-specific parameters that are used to influence the + /// request. For example, some vendors might utilize environment + /// variables, configuration files on disk, or other means to influence + /// the serialization formats used by clients or servers. + /// + /// If a `vendorParamsShape` is set, these parameters MUST be compatible + /// with that shape's definition. + vendorParams: Document + + /// A shape to be used to validate the `vendorParams` member contents. + /// + /// If set, the parameters in `vendorParams` MUST be compatible with this + /// shape's definition. + @idRef(selector: "structure", failWhenMissing: true) + vendorParamsShape: String + + /// A description of the test and what is being asserted. + documentation: String + + /// Indicates that the test case is only to be implemented by "client" or + /// "server" implementations. This property is useful for identifying and + /// testing edge cases of clients and servers that are impossible or + /// undesirable to test in *both* client and server implementations. + appliesTo: AppliesTo +} + +/// A structure defining http request shapes for initial requests. +structure InitialHttpRequest with [HttpRequestMixin] {} + +/// A structure defining http response shapes for initial responses. +structure InitialHttpResponse with [HttpResponseMixin] {} + +/// A list of events sent over the event stream. +@private +list Events { + member: Event +} + +/// An event sent over the event stream. +@private +structure Event { + /// The type of event - request or response. + @required + type: EventType + + /// The parameters used to generate the event. + /// + /// If set, these parameters MUST be compatible with a modeled event. + /// + /// If not set, this event represents an unmodeled event. + params: Document + + /// A map of expected headers. + /// + /// Headers that are not listed in this map are ignored unless they are + /// explicitly forbidden through `forbidHeaders`. + headers: EventHeaders + + /// A list of header field names that must not appear in the serialized + /// event. + forbidHeaders: StringList + + /// A list of header field names that must appear in the serialized + /// event, but no assertion is made on the value. + /// + /// Headers listed in `headers` do not need to appear in this list. + requireHeaders: StringList + + /// The expected event body. + /// + /// If no request body is defined, then no assertions are made about + /// the body of the event. + body: Blob + + /// The media type of the `body`. + /// + /// This is used to help test runners to parse and validate the expected + /// data against generated data. + bodyMediaType: String + + /// An optional binary representation of the entire event. + /// + /// This is used to test deserialization. If set, implementations SHOULD + /// use this value to represent the binary value of received events rather + /// than constructing that binary value from the other properties of the + /// event. + /// + /// This value SHOULD NOT be used to make assertions about serialized + /// events as such assertions likely would not be reliable. They would + /// suffer from the same problems of making body assertions without a + /// bodyMediaType where nonspecified ordering and optional whitespace + /// can cause semantically equivalent values to have different bytes. This + /// is made worse by headers having no defined order, and is likely made + /// even worse by common event framing features such as checksums. + bytes: Blob + + /// Defines vendor-specific parameters that are used to influence the + /// request. For example, some vendors might utilize environment + /// variables, configuration files on disk, or other means to influence + /// the serialization formats used by clients or servers. + /// + /// If a `vendorParamsShape` is set, these parameters MUST be compatible + /// with that shape's definition. + vendorParams: Document = {} + + /// A shape to be used to validate the `vendorParams` member contents. + /// + /// If set, the parameters in `vendorParams` MUST be compatible with this + /// shape's definition. + @idRef(selector: "structure", failWhenMissing: true) + vendorParamsShape: String +} + +/// The different types of event that are able to be sent over event streams. +@private +enum EventType { + /// Indicates the event is a request message. + REQUEST = "request" + + /// Indicates the event is a response message. + RESPONSE = "response" +} + +/// A map of event headers. The value is a union to indicate type, +/// but it MUST be serialized / deserialized without any sort of +/// wrapping from the union. +/// +/// The union value type is needed to disambiguate types that the +/// Smithy Node would otherwise not be able to. This can't be a +/// simple map like for HTTP headers because HTTP headers only have +/// one value type, and the Smithy IDL isn't able to distinguish +/// between types like byte/short without some additional layer. +@private +map EventHeaders { + key: String + value: EventHeaderValue +} + +/// A typed event header value. This is needed to disambiguate +/// types that the Smithy Node would otherwise not be able to. +@private +union EventHeaderValue { + boolean: Boolean + + /// Byte headers MUST be written in the model as base64-encoded + /// strings, e.g. `Zm9v` represents utf8 `foo`. + byte: Byte + short: Short + integer: Integer + long: Long + blob: Blob + string: String + timestamp: Timestamp +} + +/// The different kinds of outcomes a test case may have. +@private +union TestExpectation { + /// Indicates that the test should pass successfully. + success: Unit + + /// Indicates that the test is expected to throw an exception. + failure: TestFailureExpectation +} + +/// Indicates that the test is expected to throw an error. +@private +structure TestFailureExpectation { + /// If specified, the error must be of the targeted type. + @idRef(failWhenMissing: true, selector: "[trait|error]") + errorId: String +} + +// The following mixins are shared mixins for HTTP protocol test traits. +/// A mixin that adds HTTP message members and supporting protocol test members, +/// suitable for both requests and responses. +@private +@mixin +structure HttpMessageMixin { + /// Defines a map of expected HTTP headers. + /// + /// Headers that are not listed in this map are ignored unless they are + /// explicitly forbidden through `forbidHeaders`. + headers: StringMap + + /// A list of header field names that must not appear in the serialized + /// HTTP request. + forbidHeaders: StringList + + /// A list of header field names that must appear in the serialized + /// HTTP message, but no assertion is made on the value. + /// + /// Headers listed in `headers` do not need to appear in this list. + requireHeaders: StringList + + /// The expected HTTP message body. + /// + /// If no request body is defined, then no assertions are made about + /// the body of the message. + body: String + + /// The media type of the `body`. + /// + /// This is used to help test runners to parse and validate the expected + /// data against generated data. + bodyMediaType: String +} + +/// A mixin that adds HTTP request members and supporting protocol test members. +@private +@mixin +structure HttpRequestMixin with [HttpMessageMixin] { + /// The expected serialized HTTP request method. + @required + @length(min: 1) + method: String + + /// The request-target of the HTTP request, not including + /// the query string (for example, "/foo/bar"). + @required + @length(min: 1) + uri: String + + /// The host / endpoint provided to the client, not including the path + /// or scheme (for example, "example.com"). + host: String + + /// The host / endpoint that the client should send to, not including + /// the path or scheme (for example, "prefix.example.com"). + /// + /// This can differ from the host provided to the client if the `hostPrefix` + /// member of the `endpoint` trait is set, for instance. + resolvedHost: String + + /// A list of the expected serialized query string parameters. + /// + /// Each element in the list is a query string key value pair + /// that starts with the query string parameter name optionally + /// followed by "=", optionally followed by the query string + /// parameter value. For example, "foo=bar", "foo=", and "foo" + /// are all valid values. The query string parameter name and + /// the value MUST appear in the format in which it is expected + /// to be sent over the wire; if a key or value needs to be + /// percent-encoded, then it MUST appear percent-encoded in this list. + /// + /// A serialized HTTP request is not in compliance with the protocol + /// if any query string parameter defined in `queryParams` is not + /// defined in the request or if the value of a query string parameter + /// in the request differs from the expected value. + /// + /// `queryParams` applies no constraints on additional query parameters. + queryParams: StringList + + /// A list of query string parameter names that must not appear in the + /// serialized HTTP request. + /// + /// Each value MUST appear in the format in which it is sent over the + /// wire; if a key needs to be percent-encoded, then it MUST appear + /// percent-encoded in this list. + forbidQueryParams: StringList + + /// A list of query string parameter names that MUST appear in the + /// serialized request URI, but no assertion is made on the value. + /// + /// Each value MUST appear in the format in which it is sent over the + /// wire; if a key needs to be percent-encoded, then it MUST appear + /// percent-encoded in this list. + requireQueryParams: StringList +} + +/// A mixin that adds HTTP response members and supporting protocol test members. +@private +@mixin +structure HttpResponseMixin with [HttpMessageMixin] { + /// Defines the HTTP response code. + @required + @range(min: 100, max: 599) + code: Integer +} diff --git a/smithy-protocol-test-traits/src/test/resources/software/amazon/smithy/protocoltests/traits/errorfiles/events-duplicate-ids.errors b/smithy-protocol-test-traits/src/test/resources/software/amazon/smithy/protocoltests/traits/errorfiles/events-duplicate-ids.errors new file mode 100644 index 00000000000..ea62bac9c1a --- /dev/null +++ b/smithy-protocol-test-traits/src/test/resources/software/amazon/smithy/protocoltests/traits/errorfiles/events-duplicate-ids.errors @@ -0,0 +1 @@ +[ERROR] smithy.example#PublishMessages: Conflicting `smithy.test#eventStreamTests` test case IDs found for ID `duplicate`: `smithy.example#PublishMessages`, `smithy.example#PublishMessages` | UniqueProtocolTestCaseId diff --git a/smithy-protocol-test-traits/src/test/resources/software/amazon/smithy/protocoltests/traits/errorfiles/events-duplicate-ids.smithy b/smithy-protocol-test-traits/src/test/resources/software/amazon/smithy/protocoltests/traits/errorfiles/events-duplicate-ids.smithy new file mode 100644 index 00000000000..0f0142baf41 --- /dev/null +++ b/smithy-protocol-test-traits/src/test/resources/software/amazon/smithy/protocoltests/traits/errorfiles/events-duplicate-ids.smithy @@ -0,0 +1,51 @@ +$version: "2.0" + +namespace smithy.example + +use smithy.test#eventStreamTests + +@trait +@protocolDefinition +structure testProtocol {} + +@eventStreamTests([ + { + id: "duplicate" + protocol: testProtocol + events: [{ + type: "request" + params: { + message: { + message: "foo" + } + } + }] + } + { + id: "duplicate" + protocol: testProtocol + events: [{ + type: "request" + params: { + message: { + message: "bar" + } + } + }] + } +]) +operation PublishMessages { + input := { + stream: MessageStream + } +} + +@streaming +union MessageStream { + message: MessageEvent +} + +structure MessageEvent { + @required + message: String +} diff --git a/smithy-protocol-test-traits/src/test/resources/software/amazon/smithy/protocoltests/traits/errorfiles/events-must-define-an-event.errors b/smithy-protocol-test-traits/src/test/resources/software/amazon/smithy/protocoltests/traits/errorfiles/events-must-define-an-event.errors new file mode 100644 index 00000000000..ea87654e64e --- /dev/null +++ b/smithy-protocol-test-traits/src/test/resources/software/amazon/smithy/protocoltests/traits/errorfiles/events-must-define-an-event.errors @@ -0,0 +1 @@ +[ERROR] smithy.example#PublishMessages: smithy.test#eventStreamTests.3: At least one event, an initial request, or an initial response must be set. | EventStreamTestsTrait.3 diff --git a/smithy-protocol-test-traits/src/test/resources/software/amazon/smithy/protocoltests/traits/errorfiles/events-must-define-an-event.smithy b/smithy-protocol-test-traits/src/test/resources/software/amazon/smithy/protocoltests/traits/errorfiles/events-must-define-an-event.smithy new file mode 100644 index 00000000000..e6fb0905eec --- /dev/null +++ b/smithy-protocol-test-traits/src/test/resources/software/amazon/smithy/protocoltests/traits/errorfiles/events-must-define-an-event.smithy @@ -0,0 +1,63 @@ +$version: "2.0" + +namespace smithy.example + +use smithy.test#eventStreamTests + +@trait +@protocolDefinition +structure testProtocol {} + +@eventStreamTests([ + { + id: "hasEvent" + protocol: testProtocol + events: [{ + type: "request" + params: { + message: { + message: "foo" + } + } + }] + } + { + id: "hasInitialRequest" + protocol: testProtocol + initialRequestParams: { + room: "smithy" + } + } + { + id: "hasInitialResponse" + protocol: testProtocol + initialResponseParams: { + room: "smithy" + } + } + { + id: "invalid" + protocol: testProtocol + } +]) +operation PublishMessages { + input := { + @httpHeader("x-chat-room") + room: String + stream: MessageStream + } + output := { + @httpHeader("x-chat-room") + room: String + } +} + +@streaming +union MessageStream { + message: MessageEvent +} + +structure MessageEvent { + @required + message: String +} diff --git a/smithy-protocol-test-traits/src/test/resources/software/amazon/smithy/protocoltests/traits/errorfiles/events-validates-media-type.errors b/smithy-protocol-test-traits/src/test/resources/software/amazon/smithy/protocoltests/traits/errorfiles/events-validates-media-type.errors new file mode 100644 index 00000000000..5fc6f25b895 --- /dev/null +++ b/smithy-protocol-test-traits/src/test/resources/software/amazon/smithy/protocoltests/traits/errorfiles/events-validates-media-type.errors @@ -0,0 +1,2 @@ +[DANGER] smithy.example#PublishMessages: smithy.test#eventStreamTests.1.events.0.body: Invalid application/json content: Error parsing JSON: Expected ':' ([1, 19]) | EventStreamTestsTrait +[DANGER] smithy.example#PublishMessages: smithy.test#eventStreamTests.3.events.0.body: Invalid application/xml content: Content is not allowed in prolog. | EventStreamTestsTrait diff --git a/smithy-protocol-test-traits/src/test/resources/software/amazon/smithy/protocoltests/traits/errorfiles/events-validates-media-type.smithy b/smithy-protocol-test-traits/src/test/resources/software/amazon/smithy/protocoltests/traits/errorfiles/events-validates-media-type.smithy new file mode 100644 index 00000000000..73e6a61eb25 --- /dev/null +++ b/smithy-protocol-test-traits/src/test/resources/software/amazon/smithy/protocoltests/traits/errorfiles/events-validates-media-type.smithy @@ -0,0 +1,63 @@ +$version: "2.0" + +namespace smithy.example + +use smithy.test#eventStreamTests + +@trait +@protocolDefinition +structure testProtocol {} + +@eventStreamTests([ + { + id: "validJson" + protocol: testProtocol + events: [{ + type: "request" + body: "{}" + bodyMediaType: "application/json" + }] + } + { + id: "invalidJson" + protocol: testProtocol + events: [{ + type: "request" + body: "{\"keyWithoutValue\"}" + bodyMediaType: "application/json" + }] + } + { + id: "validXml" + protocol: testProtocol + events: [{ + type: "request" + body: "" + bodyMediaType: "application/xml" + }] + } + { + id: "invalidXml" + protocol: testProtocol + events: [{ + type: "request" + body: "{}" + bodyMediaType: "application/xml" + }] + } +]) +operation PublishMessages { + input := { + stream: MessageStream + } +} + +@streaming +union MessageStream { + message: MessageEvent +} + +structure MessageEvent { + @required + message: String +} diff --git a/smithy-protocol-test-traits/src/test/resources/software/amazon/smithy/protocoltests/traits/errorfiles/events-validates-params.errors b/smithy-protocol-test-traits/src/test/resources/software/amazon/smithy/protocoltests/traits/errorfiles/events-validates-params.errors new file mode 100644 index 00000000000..86e3e35628d --- /dev/null +++ b/smithy-protocol-test-traits/src/test/resources/software/amazon/smithy/protocoltests/traits/errorfiles/events-validates-params.errors @@ -0,0 +1,12 @@ +[WARNING] smithy.example#ExchangeMessages: smithy.test#eventStreamTests.0.initialRequestParams: Member `roomId` does not exist in `smithy.example#ExchangeMessagesInput` | EventStreamTestsTrait.UnknownMember.smithy.example#ExchangeMessagesInput.roomId +[ERROR] smithy.example#ExchangeMessages: smithy.test#eventStreamTests.1.initialRequest: Missing required structure member `roomId` for `smithy.example#RoomContext` | EventStreamTestsTrait +[WARNING] smithy.example#ExchangeMessages: smithy.test#eventStreamTests.1.initialRequest: Member `room` does not exist in `smithy.example#RoomContext` | EventStreamTestsTrait.UnknownMember.smithy.example#RoomContext.room +[WARNING] smithy.example#ExchangeMessages: smithy.test#eventStreamTests.2.initialResponseParams: Member `roomId` does not exist in `smithy.example#ExchangeMessagesOutput` | EventStreamTestsTrait.UnknownMember.smithy.example#ExchangeMessagesOutput.roomId +[ERROR] smithy.example#ExchangeMessages: smithy.test#eventStreamTests.3.initialResponse: Missing required structure member `roomId` for `smithy.example#RoomContext` | EventStreamTestsTrait +[WARNING] smithy.example#ExchangeMessages: smithy.test#eventStreamTests.3.initialResponse: Member `room` does not exist in `smithy.example#RoomContext` | EventStreamTestsTrait.UnknownMember.smithy.example#RoomContext.room +[ERROR] smithy.example#ExchangeMessages: smithy.test#eventStreamTests.4.vendorParams: Missing required structure member `region` for `smithy.example#VendorParams` | EventStreamTestsTrait +[WARNING] smithy.example#ExchangeMessages: smithy.test#eventStreamTests.4.vendorParams: Member `location` does not exist in `smithy.example#VendorParams` | EventStreamTestsTrait.UnknownMember.smithy.example#VendorParams.location +[ERROR] smithy.example#ExchangeMessages: smithy.test#eventStreamTests.5.events.0.params.message: Expected object value for structure shape, `smithy.example#MessageEvent`; found string value, `smithy` | EventStreamTestsTrait +[ERROR] smithy.example#ExchangeMessages: smithy.test#eventStreamTests.6.events.0.params.message: Expected object value for structure shape, `smithy.example#MessageEvent`; found string value, `smithy` | EventStreamTestsTrait +[ERROR] smithy.example#ExchangeMessages: smithy.test#eventStreamTests.7.events.0.vendorParams: Missing required structure member `region` for `smithy.example#VendorParams` | EventStreamTestsTrait +[WARNING] smithy.example#ExchangeMessages: smithy.test#eventStreamTests.7.events.0.vendorParams: Member `dialect` does not exist in `smithy.example#VendorParams` | EventStreamTestsTrait.UnknownMember.smithy.example#VendorParams.dialect diff --git a/smithy-protocol-test-traits/src/test/resources/software/amazon/smithy/protocoltests/traits/errorfiles/events-validates-params.smithy b/smithy-protocol-test-traits/src/test/resources/software/amazon/smithy/protocoltests/traits/errorfiles/events-validates-params.smithy new file mode 100644 index 00000000000..5cf5724b716 --- /dev/null +++ b/smithy-protocol-test-traits/src/test/resources/software/amazon/smithy/protocoltests/traits/errorfiles/events-validates-params.smithy @@ -0,0 +1,127 @@ +$version: "2.0" + +namespace smithy.example + +use smithy.test#eventStreamTests + +@trait +@protocolDefinition +structure testProtocol {} + +@eventStreamTests([ + { + id: "nonMatchingInitialRequestParams" + protocol: testProtocol + initialRequestParams: { + roomId: "smithy" + } + } + { + id: "nonMatchingInitialRequest" + protocol: testProtocol + initialRequestParams: { + room: "smithy" + } + initialRequest: { + room: "smithy" + } + initialRequestShape: RoomContext + } + { + id: "nonMatchingInitialResponseParams" + protocol: testProtocol + initialResponseParams: { + roomId: "smithy" + } + } + { + id: "nonMatchingInitialResponse" + protocol: testProtocol + initialResponseParams: { + room: "smithy" + } + initialResponse: { + room: "smithy" + } + initialResponseShape: RoomContext + } + { + id: "nonMatchingVendorParams" + protocol: testProtocol + initialRequestParams: { + room: "smithy" + } + vendorParams: { + location: "central" + } + vendorParamsShape: VendorParams + } + { + id: "nonMatchingRequestEventParams" + protocol: testProtocol + events: [{ + type: "request" + params: { + message: "smithy" + } + }] + } + { + id: "nonMatchingResponseEventParams" + protocol: testProtocol + events: [{ + type: "response" + params: { + message: "smithy" + } + }] + } + { + id: "nonMatchingEventVendorParams" + protocol: testProtocol + events: [{ + type: "request" + params: { + message: { + message: "foo" + } + } + vendorParams: { + dialect: "en_US" + } + vendorParamsShape: VendorParams + }] + } +]) +operation ExchangeMessages { + input := { + @httpHeader("x-chat-room") + room: String + stream: MessageStream + } + output := { + @httpHeader("x-chat-room") + room: String + stream: MessageStream + } +} + +@streaming +union MessageStream { + message: MessageEvent +} + +structure MessageEvent { + @required + message: String +} + +structure RoomContext { + @required + roomId: String +} + +structure VendorParams { + @required + region: String +} diff --git a/smithy-protocol-test-traits/src/test/resources/software/amazon/smithy/protocoltests/traits/errorfiles/events-validates-stream-presence.errors b/smithy-protocol-test-traits/src/test/resources/software/amazon/smithy/protocoltests/traits/errorfiles/events-validates-stream-presence.errors new file mode 100644 index 00000000000..753beb07f54 --- /dev/null +++ b/smithy-protocol-test-traits/src/test/resources/software/amazon/smithy/protocoltests/traits/errorfiles/events-validates-stream-presence.errors @@ -0,0 +1,2 @@ +[ERROR] smithy.example#PublishMessages: smithy.test#eventStreamTests.0.events.0: Invalid response event for operation smithy.example#PublishMessages that has no output stream. | EventStreamTestsTrait +[ERROR] smithy.example#ReceiveMessages: smithy.test#eventStreamTests.0.events.0: Invalid request event for operation smithy.example#ReceiveMessages that has no input stream. | EventStreamTestsTrait diff --git a/smithy-protocol-test-traits/src/test/resources/software/amazon/smithy/protocoltests/traits/errorfiles/events-validates-stream-presence.smithy b/smithy-protocol-test-traits/src/test/resources/software/amazon/smithy/protocoltests/traits/errorfiles/events-validates-stream-presence.smithy new file mode 100644 index 00000000000..c5c267ad902 --- /dev/null +++ b/smithy-protocol-test-traits/src/test/resources/software/amazon/smithy/protocoltests/traits/errorfiles/events-validates-stream-presence.smithy @@ -0,0 +1,59 @@ +$version: "2.0" + +namespace smithy.example + +use smithy.test#eventStreamTests + +@trait +@protocolDefinition +structure testProtocol {} + +@eventStreamTests([ + { + id: "missingOutputStream" + protocol: testProtocol + events: [{ + type: "response" + params: { + message: { + message: "foo" + } + } + }] + } +]) +operation PublishMessages { + input := { + stream: MessageStream + } +} + +@eventStreamTests([ + { + id: "missingInputStream" + protocol: testProtocol + events: [{ + type: "request" + params: { + message: { + message: "foo" + } + } + }] + } +]) +operation ReceiveMessages { + output := { + stream: MessageStream + } +} + +@streaming +union MessageStream { + message: MessageEvent +} + +structure MessageEvent { + @required + message: String +}