diff --git a/api/src/main/java/io/cloudevents/v03/AttributesImpl.java b/api/src/main/java/io/cloudevents/v03/AttributesImpl.java new file mode 100644 index 00000000..7e88d5a3 --- /dev/null +++ b/api/src/main/java/io/cloudevents/v03/AttributesImpl.java @@ -0,0 +1,122 @@ +package io.cloudevents.v03; + +import java.net.URI; +import java.time.ZonedDateTime; +import java.util.Optional; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Pattern; +import javax.validation.constraints.Size; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; + +import io.cloudevents.Attributes; +import io.cloudevents.json.ZonedDateTimeDeserializer; + +/** + * The event attributes implementation for v0.2 + * + * @author fabiojose + * + */ +@JsonInclude(value = Include.NON_ABSENT) +public class AttributesImpl implements Attributes { + + @NotBlank + private final String id; + + @NotNull + private final URI source; + + @NotBlank + @Pattern(regexp = "0\\.3") + private final String specversion; + + @NotBlank + private final String type; + + @JsonDeserialize(using = ZonedDateTimeDeserializer.class) + private final ZonedDateTime time; + private final URI schemaurl; + + @Pattern(regexp = "base64") + private final String datacontentencoding; + private final String datacontenttype; + + @Size(min = 1) + private final String subject; + + AttributesImpl(String id, URI source, String specversion, String type, + ZonedDateTime time, URI schemaurl, String datacontentencoding, + String datacontenttype, String subject) { + this.id = id; + this.source = source; + this.specversion = specversion; + this.type = type; + + this.time = time; + this.schemaurl = schemaurl; + this.datacontentencoding = datacontentencoding; + this.datacontenttype = datacontenttype; + this.subject = subject; + } + + public String getId() { + return id; + } + public URI getSource() { + return source; + } + public String getSpecversion() { + return specversion; + } + public String getType() { + return type; + } + public Optional getTime() { + return Optional.ofNullable(time); + } + public Optional getSchemaurl() { + return Optional.ofNullable(schemaurl); + } + public Optional getDatacontentencoding() { + return Optional.ofNullable(datacontentencoding); + } + public Optional getDatacontenttype() { + return Optional.ofNullable(datacontenttype); + } + public Optional getSubject() { + return Optional.ofNullable(subject); + } + + + + @Override + public String toString() { + return "AttributesImpl [id=" + id + ", source=" + source + ", specversion=" + + specversion + ", type=" + type + ", time=" + time + ", schemaurl=" + schemaurl + ", datacontentencoding=" + datacontentencoding + + ", datacontenttype=" + datacontenttype + ", subject=" + subject + "]"; + } + + + @JsonCreator + public static AttributesImpl build( + @JsonProperty("id") String id, + @JsonProperty("source") URI source, + @JsonProperty("specversion") String specversion, + @JsonProperty("type") String type, + @JsonProperty("time") ZonedDateTime time, + @JsonProperty("schemaurl") URI schemaurl, + @JsonProperty("datacontentenconding") String datacontentencoding, + @JsonProperty("datacontenttype") String datacontenttype, + @JsonProperty("subject") String subject) { + + return new AttributesImpl(id, source, specversion, type, time, + schemaurl, datacontentencoding, datacontenttype, subject); + } +} \ No newline at end of file diff --git a/api/src/main/java/io/cloudevents/v03/CloudEventBuilder.java b/api/src/main/java/io/cloudevents/v03/CloudEventBuilder.java new file mode 100644 index 00000000..d2f5de8f --- /dev/null +++ b/api/src/main/java/io/cloudevents/v03/CloudEventBuilder.java @@ -0,0 +1,153 @@ +package io.cloudevents.v03; + +import static java.lang.String.format; + +import java.net.URI; +import java.time.ZonedDateTime; +import java.util.HashSet; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import javax.validation.ConstraintViolation; +import javax.validation.Validation; +import javax.validation.Validator; + +import io.cloudevents.Event; +import io.cloudevents.ExtensionFormat; + +/** + * The event builder. + * + * @author fabiojose + * + */ +public final class CloudEventBuilder { + + private static Validator VALIDATOR; + + public static final String SPEC_VERSION = "0.3"; + private static final String MESSAGE_SEPARATOR = ", "; + private static final String MESSAGE = "'%s' %s"; + private static final String ERR_MESSAGE = "invalid payload: %s"; + + private String id; + private URI source; + + private String type; + + private ZonedDateTime time; + private URI schemaurl; + private String datacontentencoding; + private String datacontenttype; + private String subject; + + private T data; + + private final Set extensions = new HashSet<>(); + + private CloudEventBuilder() {} + + private static Validator getValidator() { + if(null== VALIDATOR) { + VALIDATOR = Validation.buildDefaultValidatorFactory().getValidator(); + } + return VALIDATOR; + } + + /** + * Gets a brand new builder instance + * @param The 'data' type + */ + public static CloudEventBuilder builder() { + return new CloudEventBuilder(); + } + + /** + * + * @return An new {@link Event} immutable instance + * @throws IllegalStateException When there are specification constraints + * violations + */ + public CloudEventImpl build() { + + AttributesImpl attributes = new AttributesImpl(id, source, SPEC_VERSION, + type, time, schemaurl, datacontentencoding, datacontenttype, + subject); + + CloudEventImpl cloudEvent = + new CloudEventImpl(attributes, data, extensions); + + Set> violations = + getValidator().validate(cloudEvent); + + violations.addAll(getValidator().validate(cloudEvent.getAttributes())); + + final String errs = + violations.stream() + .map(v -> format(MESSAGE, v.getPropertyPath(), v.getMessage())) + .collect(Collectors.joining(MESSAGE_SEPARATOR)); + + Optional.ofNullable( + "".equals(errs) ? null : errs + + ).ifPresent((e) -> { + throw new IllegalStateException(format(ERR_MESSAGE, e)); + }); + + return cloudEvent; + } + + public CloudEventBuilder withId(String id) { + this.id = id; + return this; + } + + public CloudEventBuilder withSource(URI source) { + this.source = source; + return this; + } + + public CloudEventBuilder withType(String type) { + this.type = type; + return this; + } + + public CloudEventBuilder withTime(ZonedDateTime time) { + this.time = time; + return this; + } + + public CloudEventBuilder withSchemaurl(URI schemaurl) { + this.schemaurl = schemaurl; + return this; + } + + public CloudEventBuilder withDatacontentencoding( + String datacontentencoding) { + this.datacontentencoding = datacontentencoding; + return this; + } + + public CloudEventBuilder withDatacontenttype( + String datacontenttype) { + this.datacontenttype = datacontenttype; + return this; + } + + public CloudEventBuilder withSubject( + String subject) { + this.subject = subject; + return this; + } + + public CloudEventBuilder withData(T data) { + this.data = data; + return this; + } + + public CloudEventBuilder withExtension(ExtensionFormat extension) { + this.extensions.add(extension); + return this; + } +} diff --git a/api/src/main/java/io/cloudevents/v03/CloudEventImpl.java b/api/src/main/java/io/cloudevents/v03/CloudEventImpl.java new file mode 100644 index 00000000..62f9eedc --- /dev/null +++ b/api/src/main/java/io/cloudevents/v03/CloudEventImpl.java @@ -0,0 +1,113 @@ +package io.cloudevents.v03; + +import java.net.URI; +import java.time.ZonedDateTime; +import java.util.Collections; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import javax.validation.constraints.NotNull; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonUnwrapped; +import com.fasterxml.jackson.annotation.JsonInclude.Include; + +import io.cloudevents.Event; +import io.cloudevents.ExtensionFormat; + +/** + * The event implementation + * + * @author fabiojose + * + */ +@JsonInclude(value = Include.NON_ABSENT) +public class CloudEventImpl implements Event { + + @JsonIgnore + @JsonUnwrapped + @NotNull + private final AttributesImpl attributes; + + private final T data; + + @NotNull + private final Map extensions; + + CloudEventImpl(AttributesImpl attributes, T data, + Set extensions) { + this.attributes = attributes; + this.data = data; + + this.extensions = extensions + .stream() + .collect(Collectors + .toMap(ExtensionFormat::getKey, + ExtensionFormat::getExtension)); + } + + @Override + public AttributesImpl getAttributes() { + return attributes; + } + + @Override + public Optional getData() { + return Optional.ofNullable(data); + } + + @JsonAnyGetter + @Override + public Map getExtensions() { + return Collections.unmodifiableMap(extensions); + } + + /** + * The unique method that allows mutable. Used by + * Jackson Framework to inject the extensions. + * + * @param name Extension name + * @param value Extension value + */ + @JsonAnySetter + void addExtension(String name, Object value) { + extensions.put(name, value); + } + + /** + * Used by the Jackson Framework to unmarshall. + */ + @JsonCreator + public static CloudEventImpl build( + @JsonProperty("id") String id, + @JsonProperty("source") URI source, + @JsonProperty("specversion") String specversion, + @JsonProperty("type") String type, + @JsonProperty("time") ZonedDateTime time, + @JsonProperty("schemaurl") URI schemaurl, + @JsonProperty("datacontentencoding") String datacontentencoding, + @JsonProperty("datacontenttype") String datacontenttype, + @JsonProperty("subject") String subject, + @JsonProperty("data") T data) { + + return CloudEventBuilder.builder() + .withId(id) + .withSource(source) + .withType(type) + .withTime(time) + .withSchemaurl(schemaurl) + .withDatacontentencoding(datacontentencoding) + .withDatacontenttype(datacontenttype) + .withData(data) + .withSubject(subject) + .build(); + } + +} diff --git a/api/src/test/java/io/cloudevents/v03/CloudEventBuilderTest.java b/api/src/test/java/io/cloudevents/v03/CloudEventBuilderTest.java new file mode 100644 index 00000000..5996d6b0 --- /dev/null +++ b/api/src/test/java/io/cloudevents/v03/CloudEventBuilderTest.java @@ -0,0 +1,138 @@ +package io.cloudevents.v03; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import java.net.URI; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; + +import io.cloudevents.Event; + +/** + * + * @author fabiojose + * + */ +public class CloudEventBuilderTest { + + @Rule + public ExpectedException expectedEx = ExpectedException.none(); + + @Test + public void error_when_null_id() { + // setup + expectedEx.expect(IllegalStateException.class); + expectedEx.expectMessage("invalid payload: 'id' must not be blank"); + + // act + CloudEventBuilder.builder() + .withSource(URI.create("/test")) + .withType("type") + .build(); + } + + @Test + public void error_when_empty_id() { + // setup + expectedEx.expect(IllegalStateException.class); + expectedEx.expectMessage("invalid payload: 'id' must not be blank"); + + // act + CloudEventBuilder.builder() + .withId("") + .withSource(URI.create("/test")) + .withType("type") + .build(); + } + + @Test + public void error_when_null_type() { + // setup + expectedEx.expect(IllegalStateException.class); + expectedEx.expectMessage("invalid payload: 'type' must not be blank"); + + // act + CloudEventBuilder.builder() + .withId("id") + .withSource(URI.create("/test")) + .build(); + } + + @Test + public void error_when_empty_type() { + // setup + expectedEx.expect(IllegalStateException.class); + expectedEx.expectMessage("invalid payload: 'type' must not be blank"); + + // act + CloudEventBuilder.builder() + .withId("id") + .withSource(URI.create("/test")) + .withType("") + .build(); + } + + @Test + public void error_when_null_source() { + // setup + expectedEx.expect(IllegalStateException.class); + expectedEx.expectMessage("invalid payload: 'source' must not be null"); + + // act + CloudEventBuilder.builder() + .withId("id") + .withType("type") + .build(); + } + + @Test + public void error_when_empty_subject() { + // setup + expectedEx.expect(IllegalStateException.class); + expectedEx.expectMessage("invalid payload: 'subject' size must be between 1 and 2147483647"); + + // act + CloudEventBuilder.builder() + .withId("id") + .withType("type") + .withSource(URI.create("/source")) + .withSubject("") + .build(); + } + + @Test + public void error_when_invalid_encoding() { + // setup + expectedEx.expect(IllegalStateException.class); + expectedEx.expectMessage("invalid payload: 'datacontentencoding' must match \"base64\""); + + // act + CloudEventBuilder.builder() + .withId("id") + .withType("type") + .withSource(URI.create("/source")) + .withSubject("subject") + .withDatacontentencoding("binary") + .build(); + } + + @Test + public void should_have_subject() { + // act + Event ce = + CloudEventBuilder.builder() + .withId("id") + .withSource(URI.create("/source")) + .withType("type") + .withSubject("subject") + .build(); + + // assert + assertTrue(ce.getAttributes().getSubject().isPresent()); + assertEquals("subject", ce.getAttributes().getSubject().get()); + } + +} diff --git a/api/src/test/java/io/cloudevents/v03/CloudEventJacksonTest.java b/api/src/test/java/io/cloudevents/v03/CloudEventJacksonTest.java new file mode 100644 index 00000000..038d395d --- /dev/null +++ b/api/src/test/java/io/cloudevents/v03/CloudEventJacksonTest.java @@ -0,0 +1,249 @@ +package io.cloudevents.v03; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import java.io.InputStream; +import java.net.URI; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; + +import com.fasterxml.jackson.core.type.TypeReference; + +import io.cloudevents.Event; +import io.cloudevents.ExtensionFormat; +import io.cloudevents.extensions.DistributedTracingExtension; +import io.cloudevents.json.Json; +import io.cloudevents.json.types.Much; + +/** + * + * @author fabiojose + * + */ +public class CloudEventJacksonTest { + + private static InputStream resourceOf(String name) { + return Thread.currentThread().getContextClassLoader().getResourceAsStream(name); + } + + @Rule + public ExpectedException expectedEx = ExpectedException.none(); + + @Test + public void should_encode_right_with_minimal_attrs() { + // setup + Event ce = + CloudEventBuilder.builder() + .withId("x10") + .withSource(URI.create("/source")) + .withType("event-type") + .build(); + + // act + String json = Json.encode(ce); + System.out.println(json); + + // assert + assertTrue(json.contains("x10")); + assertTrue(json.contains("/source")); + assertTrue(json.contains("event-type")); + assertTrue(json.contains("0.3")); + + assertFalse(json.contains("time")); + assertFalse(json.contains("schemaurl")); + assertFalse(json.contains("contenttype")); + assertFalse(json.contains("data")); + } + + @Test + public void should_have_optional_attrs() { + // setup + Event ce = + CloudEventBuilder.builder() + .withId("x10") + .withSource(URI.create("/source")) + .withType("event-type") + .withSchemaurl(URI.create("/schema")) + .withDatacontenttype("text/plain") + .withDatacontentencoding("base64") + .withSubject("subject0") + .withData("my-data") + .build(); + + // act + String json = Json.encode(ce); + + // assert + assertTrue(json.contains("/schema")); + assertTrue(json.contains("text/plain")); + assertTrue(json.contains("my-data")); + assertTrue(json.contains("\"base64\"")); + assertTrue(json.contains("subject0")); + + assertTrue(json.contains("\"schemaurl\"")); + assertTrue(json.contains("datacontenttype")); + assertTrue(json.contains("datacontentencoding")); + assertTrue(json.contains("\"subject\"")); + } + + @Test + public void should_serialize_trace_extension() { + // setup + String expected = "\"distributedTracing\":{\"traceparent\":\"0\",\"tracestate\":\"congo=4\"}"; + final DistributedTracingExtension dt = new DistributedTracingExtension(); + dt.setTraceparent("0"); + dt.setTracestate("congo=4"); + + final ExtensionFormat tracing = new DistributedTracingExtension.InMemory(dt); + + Event ce = + CloudEventBuilder.builder() + .withId("x10") + .withSource(URI.create("/source")) + .withType("event-type") + .withSchemaurl(URI.create("/schema")) + .withDatacontenttype("text/plain") + .withData("my-data") + .withExtension(tracing) + .build(); + + // act + String actual = Json.encode(ce); + System.out.println(actual); + + // assert + assertTrue(actual.contains(expected)); + } + + @Test + public void should_have_type() { + // act + Event ce = + Json.fromInputStream(resourceOf("03_new.json"), CloudEventImpl.class); + + // assert + assertEquals("aws.s3.object.created", ce.getAttributes().getType()); + } + + @Test + public void should_have_id() { + // act + Event ce = + Json.fromInputStream(resourceOf("03_new.json"), CloudEventImpl.class); + + // assert + assertEquals("C234-1234-1234", ce.getAttributes().getId()); + } + + //should have time + @Test + public void should_have_time() { + // act + Event ce = + Json.fromInputStream(resourceOf("03_new.json"), CloudEventImpl.class); + + // assert + assertTrue(ce.getAttributes().getTime().isPresent()); + } + + @Test + public void should_have_source() { + // act + Event ce = + Json.fromInputStream(resourceOf("03_new.json"), CloudEventImpl.class); + + // assert + assertEquals(URI.create("https://serverless.com"), ce.getAttributes().getSource()); + } + + @Test + public void should_have_datacontenttype() { + // act + Event ce = + Json.fromInputStream(resourceOf("03_new.json"), CloudEventImpl.class); + + // assert + assertTrue(ce.getAttributes().getDatacontenttype().isPresent()); + assertEquals("application/json", ce.getAttributes().getDatacontenttype().get()); + } + + @Test + public void should_have_datacontentencoding() { + // act + Event ce = + Json.fromInputStream(resourceOf("03_base64.json"), CloudEventImpl.class); + + // assert + assertTrue(ce.getAttributes().getDatacontentencoding().isPresent()); + assertEquals("base64", ce.getAttributes().getDatacontentencoding().get()); + } + + @Test + public void should_have_specversion() { + // act + Event ce = + Json.fromInputStream(resourceOf("03_new.json"), CloudEventImpl.class); + + // assert + assertEquals("0.3", ce.getAttributes().getSpecversion()); + } + + @Test + public void should_throw_when_absent() { + // setup + expectedEx.expect(IllegalStateException.class); + expectedEx.expectMessage("invalid payload: 'id' must not be blank"); + + // act + Json.fromInputStream(resourceOf("03_absent.json"), CloudEventImpl.class); + } + + @Test + public void should_have_tracing_extension() { + // act + Event ce = + Json.fromInputStream(resourceOf("03_extension.json"), CloudEventImpl.class); + + // assert + assertNotNull(ce.getExtensions() + .get(DistributedTracingExtension.InMemory.IN_MEMORY_KEY)); + } + + @Test + public void should_have_custom_extension() { + // setup + String extensionKey = "my-extension"; + String expected = "extension-value"; + + // act + Event ce = + Json.fromInputStream(resourceOf("03_extension.json"), CloudEventImpl.class); + + // assert + assertEquals(expected, ce.getExtensions() + .get(extensionKey)); + } + + @Test + public void should_have_custom_data() { + // setup + Much expected = new Much(); + expected.setWow("kinda"); + + String json = "{\"type\":\"aws.s3.object.created\",\"id\":\"C234-1234-1234\",\"time\":\"2019-08-19T19:35:00.000Z\",\"source\":\"https://serverless.com\",\"datacontenttype\":\"application/json\",\"specversion\":\"0.3\",\"data\":{\"wow\":\"kinda\"}}"; + + // act + Event ce = + Json.decodeValue(json, new TypeReference>() {}); + + // assert + assertTrue(ce.getData().isPresent()); + assertEquals(expected.getWow(), ce.getData().get().getWow()); + } + +}