polyn 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,187 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "description": "CloudEvents Specification JSON Schema, extended for Polyn",
4
+ "type": "object",
5
+ "properties": {
6
+ "id": {
7
+ "description": "Identifies the event.",
8
+ "$ref": "#/definitions/iddef",
9
+ "examples": [
10
+ "A234-1234-1234"
11
+ ]
12
+ },
13
+ "source": {
14
+ "description": "Identifies the context in which an event happened.",
15
+ "$ref": "#/definitions/sourcedef",
16
+ "examples" : [
17
+ "https://github.com/cloudevents",
18
+ "mailto:cncf-wg-serverless@lists.cncf.io",
19
+ "urn:uuid:6e8bc430-9c3a-11d9-9669-0800200c9a66",
20
+ "cloudevents/spec/pull/123",
21
+ "/sensors/tn-1234567/alerts",
22
+ "1-555-123-4567"
23
+ ]
24
+ },
25
+ "specversion": {
26
+ "description": "The version of the CloudEvents specification which the event uses.",
27
+ "$ref": "#/definitions/specversiondef",
28
+ "examples": [
29
+ "1.0"
30
+ ]
31
+ },
32
+ "type": {
33
+ "description": "Describes the type of event related to the originating occurrence.",
34
+ "$ref": "#/definitions/typedef",
35
+ "examples" : [
36
+ "com.github.pull_request.opened",
37
+ "com.example.object.deleted.v2"
38
+ ]
39
+ },
40
+ "datacontenttype": {
41
+ "description": "Content type of the data value. Must adhere to RFC 2046 format.",
42
+ "$ref": "#/definitions/datacontenttypedef",
43
+ "examples": [
44
+ "text/xml",
45
+ "application/json",
46
+ "image/png",
47
+ "multipart/form-data"
48
+ ]
49
+ },
50
+ "dataschema": {
51
+ "description": "Identifies the schema that data adheres to.",
52
+ "$ref": "#/definitions/dataschemadef"
53
+ },
54
+ "subject": {
55
+ "description": "Describes the subject of the event in the context of the event producer (identified by source).",
56
+ "$ref": "#/definitions/subjectdef",
57
+ "examples": [
58
+ "mynewfile.jpg"
59
+ ]
60
+ },
61
+ "time": {
62
+ "description": "Timestamp of when the occurrence happened. Must adhere to RFC 3339.",
63
+ "$ref": "#/definitions/timedef",
64
+ "examples": [
65
+ "2018-04-05T17:31:00Z"
66
+ ]
67
+ },
68
+ "data": {
69
+ "description": "The event payload.",
70
+ "$ref": "#/definitions/datadef",
71
+ "examples": [
72
+ "<much wow=\"xml\"/>"
73
+ ]
74
+ },
75
+ "data_base64": {
76
+ "description": "Base64 encoded event payload. Must adhere to RFC4648.",
77
+ "$ref": "#/definitions/data_base64def",
78
+ "examples": [
79
+ "Zm9vYg=="
80
+ ]
81
+ },
82
+ "polyndata": {
83
+ "$ref": "#/definitions/polyndatadef",
84
+ "description": "Information about the client that produced the event and additional metadata",
85
+ "examples": [
86
+ {
87
+ "clientlang": "elixir",
88
+ "clientlangversion": "1.13.2",
89
+ "clientversion": "0.1.0"
90
+ }
91
+ ]
92
+ },
93
+ "polyntrace": {
94
+ "$ref": "#/definitions/polyntracedef",
95
+ "description": "Previous events that led to this one",
96
+ "examples": [
97
+ [
98
+ {
99
+ "type": "<topic>",
100
+ "time": "2018-04-05T17:31:00Z",
101
+ "id": "<uuid>"
102
+ }
103
+ ]
104
+ ]
105
+ }
106
+ },
107
+ "required": ["id", "source", "specversion", "type"],
108
+ "definitions": {
109
+ "iddef": {
110
+ "type": "string",
111
+ "minLength": 1
112
+ },
113
+ "sourcedef": {
114
+ "type": "string",
115
+ "format": "uri-reference",
116
+ "minLength": 1
117
+ },
118
+ "specversiondef": {
119
+ "type": "string",
120
+ "minLength": 1
121
+ },
122
+ "typedef": {
123
+ "type": "string",
124
+ "minLength": 1
125
+ },
126
+ "datacontenttypedef": {
127
+ "type": ["string", "null"],
128
+ "minLength": 1
129
+ },
130
+ "dataschemadef": {
131
+ "type": ["string", "null"],
132
+ "format": "uri",
133
+ "minLength": 1
134
+ },
135
+ "subjectdef": {
136
+ "type": ["string", "null"],
137
+ "minLength": 1
138
+ },
139
+ "timedef": {
140
+ "type": ["string", "null"],
141
+ "format": "date-time",
142
+ "minLength": 1
143
+ },
144
+ "datadef": {
145
+ "type": ["object", "string", "number", "array", "boolean", "null"]
146
+ },
147
+ "data_base64def": {
148
+ "type": ["string", "null"],
149
+ "contentEncoding": "base64"
150
+ },
151
+ "polyndatadef": {
152
+ "type": "object",
153
+ "properties": {
154
+ "clientlang": {
155
+ "type": "string"
156
+ },
157
+ "clientlangversion": {
158
+ "type": "string"
159
+ },
160
+ "clientversion": {
161
+ "type": "string"
162
+ }
163
+ },
164
+ "required": ["clientlang", "clientlangversion", "clientversion"]
165
+ },
166
+ "polyntracedef": {
167
+ "type" : "array",
168
+ "items": {
169
+ "type": "object",
170
+ "properties": {
171
+ "type": {
172
+ "type": "string"
173
+ },
174
+ "time": {
175
+ "type": "string",
176
+ "format": "date-time"
177
+ },
178
+ "id" : {
179
+ "type": "string",
180
+ "format": "uuid"
181
+ }
182
+ },
183
+ "required": ["type", "time", "id"]
184
+ }
185
+ }
186
+ }
187
+ }
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Polyn
4
+ ##
5
+ # Access cloud event information
6
+ class CloudEvent
7
+ def self.to_h
8
+ path = File.expand_path(File.join(File.dirname(__FILE__), "../cloud-event-schema.json"))
9
+ file = File.open(path)
10
+ JSON.parse(file.read)
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Polyn
4
+ ##
5
+ # Configuration data for Polyn
6
+ class Configuration
7
+ def initialize
8
+ @domain = nil
9
+ @source_root = nil
10
+ end
11
+
12
+ def domain
13
+ @domain ||= Polyn::Naming.validate_domain_name!(@domain)
14
+ end
15
+
16
+ def domain=(name)
17
+ Polyn::Naming.validate_domain_name!(name)
18
+ @domain = name
19
+ end
20
+
21
+ def source_root
22
+ @source_root ||= Polyn::Naming.validate_source_root!(@source_root)
23
+ end
24
+
25
+ def source_root=(name)
26
+ Polyn::Naming.validate_source_root!(name)
27
+ @source_root = name
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Polyn
4
+ module Errors
5
+ ##
6
+ # Raised when there is an error with Polyn configuration
7
+ class ConfigurationError < Error
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Polyn
4
+ module Errors
5
+ ##
6
+ # Polyn base error class.
7
+ class Error < StandardError
8
+ def initialize(message = nil, code: 500)
9
+ super(message)
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "polyn/errors/error"
4
+ require "polyn/errors/configuration_error"
5
+ require "polyn/errors/schema_error"
6
+ require "polyn/errors/validation_error"
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Polyn
4
+ module Errors
5
+ ##
6
+ # Raised when there are problems with the schema aside from validation
7
+ class SchemaError < Error
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Polyn
4
+ module Errors
5
+ ##
6
+ # Raised when a part of the schema is invalid
7
+ class ValidationError < Error
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright 2021-2022 Spiff, Inc.
4
+ #
5
+ # Permission is hereby granted, free of charge, to any person obtaining a copy of this
6
+ # software and associated documentation files (the "Software"), to deal in the Software
7
+ # without restriction, including without limitation the rights to use, copy, modify, merge,
8
+ # publish, distribute, sublicense, and/or sell copies of the Software, and to permit
9
+ # persons to whom the Software is furnished to do so, subject to the following conditions:
10
+ #
11
+ # The above copyright notice and this permission notice shall be included in all copies or
12
+ # substantial portions of the Software.
13
+ #
14
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
15
+ # NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
16
+ # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
17
+ # DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
19
+
20
+ module Polyn
21
+ ##
22
+ # Represents an event. Events follow the [Cloudevents](https://github.com/cloudevents)
23
+ # specification.
24
+ class Event
25
+ CLOUD_EVENT_VERSION = "1.0"
26
+
27
+ class UnsupportedVersionError < Errors::Error; end
28
+
29
+ ##
30
+ # @return [String] the cloud event version
31
+ attr_reader :specversion
32
+
33
+ ##
34
+ # @return [String] event id
35
+ attr_reader :id
36
+
37
+ ##
38
+ # @return [String] event type
39
+ attr_reader :type
40
+
41
+ ##
42
+ # @return [String] event source
43
+ attr_reader :source
44
+
45
+ ##
46
+ # @return [String] time of event creation
47
+ attr_reader :time
48
+
49
+ ##
50
+ # @return [String] the data content type
51
+ attr_accessor :datacontenttype
52
+
53
+ ##
54
+ # @return [String] the data
55
+ attr_reader :data
56
+
57
+ ##
58
+ # @return [Array] Previous events that led to this one
59
+ attr_reader :polyntrace
60
+
61
+ ##
62
+ # @return [Hash] Represents the information about the client that published the event
63
+ # as well as additional metadata
64
+ attr_reader :polyndata
65
+
66
+ def initialize(hash)
67
+ @specversion = hash.key?(:specversion) ? hash[:specversion] : "1.0"
68
+
69
+ unless Gem::Dependency.new("", "~> #{CLOUD_EVENT_VERSION}").match?("", @specversion)
70
+ raise UnsupportedVersionError, "Unsupported version: '#{hash[:specversion]}'"
71
+ end
72
+
73
+ @id = hash.fetch(:id, SecureRandom.uuid)
74
+ @type = self.class.full_type(hash.fetch(:type))
75
+ @source = self.class.full_source(hash[:source])
76
+ @time = hash.fetch(:time, Time.now.utc.iso8601)
77
+ @data = hash.fetch(:data)
78
+ @datacontenttype = hash.fetch(:datacontenttype, "application/json")
79
+ @polyntrace = self.class.build_polyntrace(hash[:triggered_by])
80
+ @polyndata = {
81
+ clientlang: "ruby",
82
+ clientlangversion: RUBY_VERSION,
83
+ clientversion: Polyn::VERSION,
84
+ }
85
+ end
86
+
87
+ def to_h
88
+ {
89
+ "specversion" => specversion,
90
+ "id" => id,
91
+ "type" => type,
92
+ "source" => source,
93
+ "time" => time,
94
+ "data" => Utils::Hash.deep_stringify_keys(data),
95
+ "datacontenttype" => datacontenttype,
96
+ "polyntrace" => Utils::Hash.deep_stringify_keys(polyntrace),
97
+ "polyndata" => Utils::Hash.deep_stringify_keys(polyndata),
98
+ }
99
+ end
100
+
101
+ ##
102
+ # Get the Event `source` prefixed with reverse domain name
103
+ def self.full_source(source = nil)
104
+ root = Polyn.configuration.source_root
105
+ name = Polyn::Naming.dot_to_colon("#{domain}:#{root}")
106
+
107
+ if source
108
+ Polyn::Naming.validate_source_name!(source)
109
+ "#{name}:#{Polyn::Naming.dot_to_colon(source)}"
110
+ else
111
+ name
112
+ end
113
+ end
114
+
115
+ ##
116
+ # Get the Event `type` prefixed with reverse domain name
117
+ def self.full_type(type)
118
+ Polyn::Naming.validate_event_type!(type)
119
+ "#{domain}.#{Polyn::Naming.trim_domain_prefix(type)}"
120
+ end
121
+
122
+ ##
123
+ # Use a triggering event to build the polyntrace of a new event
124
+ def self.build_polyntrace(triggered_by)
125
+ return [] unless triggered_by
126
+
127
+ triggered_by.polyntrace.concat([{
128
+ id: triggered_by.id,
129
+ type: triggered_by.type,
130
+ time: triggered_by.time,
131
+ }])
132
+ end
133
+
134
+ def self.domain
135
+ Polyn.configuration.domain
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Polyn
4
+ ##
5
+ # Methods for formatting and validating names of fields
6
+ class Naming
7
+ ##
8
+ # Convert a dot separated name into a colon separated name
9
+ def self.dot_to_colon(str)
10
+ str.gsub(".", ":")
11
+ end
12
+
13
+ ##
14
+ # Validate that the configured `domain` is in the correct format
15
+ def self.validate_domain_name!(name)
16
+ if name.is_a?(String) && name.match?(/\A[a-z0-9]+(?:(?:\.|:)[a-z0-9]+)*\z/)
17
+ name
18
+ else
19
+ raise Polyn::Errors::ConfigurationError,
20
+ "You must configure the `domain` for Polyn. It must be lowercase, alphanumeric and dot/colon separated, got #{name}"
21
+ end
22
+ end
23
+
24
+ ##
25
+ # Validate the `source` name
26
+ def self.validate_source_name(name)
27
+ if name.is_a?(String) && name.match?(/\A[a-z0-9]+(?:(?:\.|:)[a-z0-9]+)*\z/)
28
+ true
29
+ else
30
+ "Event source must be lowercase, alphanumeric and dot/colon separated, got #{name}"
31
+ end
32
+ end
33
+
34
+ ##
35
+ # Validate the `source` name and raise if invalid
36
+ def self.validate_source_name!(name)
37
+ message = validate_source_name(name)
38
+ if message == true
39
+ name
40
+ else
41
+ raise Polyn::Errors::ValidationError, message
42
+ end
43
+ end
44
+
45
+ ##
46
+ # Validate that the configured `source_root` is in the correct format
47
+ def self.validate_source_root!(name)
48
+ message = validate_source_name(name)
49
+ if message == true
50
+ name
51
+ else
52
+ raise Polyn::Errors::ConfigurationError,
53
+ "You must configure the `source_root` for Polyn. #{message}"
54
+ end
55
+ end
56
+
57
+ ##
58
+ # Validate the event type
59
+ def self.validate_event_type!(name)
60
+ if name.is_a?(String) && name.match?(/\A[a-z0-9]+(?:\.[a-z0-9]+)*\z/)
61
+ name
62
+ else
63
+ raise Polyn::Errors::ValidationError,
64
+ "Event types must be lowercase, alphanumeric and dot separated"
65
+ end
66
+ end
67
+
68
+ ##
69
+ # Remove the `domain` name from the beginning of a string
70
+ def self.trim_domain_prefix(str)
71
+ str = str.sub("#{domain}.", "")
72
+ str.sub("#{dot_to_colon(domain)}:", "")
73
+ end
74
+
75
+ ##
76
+ # Create a consumer name from a source and type
77
+ def self.consumer_name(type, source = nil)
78
+ validate_event_type!(type)
79
+ type = trim_domain_prefix(type)
80
+ type = type.gsub(".", "_")
81
+
82
+ root = Polyn.configuration.source_root
83
+ root = root.gsub(".", "_")
84
+ root = root.gsub(":", "_")
85
+
86
+ if source
87
+ validate_source_name!(source)
88
+ source = source.gsub(".", "_")
89
+ source = source.gsub(":", "_")
90
+ [root, source, type].join("_")
91
+ else
92
+ [root, type].join("_")
93
+ end
94
+ end
95
+
96
+ def self.domain
97
+ Polyn.configuration.domain
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Polyn
4
+ ##
5
+ # Wrapper around nats-pure that can validate polyn messages
6
+ class PullSubscriber
7
+ ##
8
+ # @param fields [Object] :nats - Connected NATS instance from `NATS.connect`
9
+ # @param fields [String] :type - The type of event
10
+ # @option fields [String] :source - If the `source` portion of the consumer name
11
+ # is more than the `source_root`
12
+ def initialize(fields)
13
+ @nats = fields.fetch(:nats)
14
+ @type = fields.fetch(:type)
15
+ @type = Polyn::Naming.trim_domain_prefix(@type)
16
+ @consumer_name = Polyn::Naming.consumer_name(@type, fields[:source])
17
+ @stream = @nats.jetstream.find_stream_name_by_subject(@type)
18
+ self.class.validate_consumer_exists!(@nats, @stream, @consumer_name)
19
+ @psub = @nats.jetstream.pull_subscribe(@type, @consumer_name)
20
+ @store_name = store_name(fields)
21
+ end
22
+
23
+ # nats-pure will create a consumer if the one you passed does not exist.
24
+ # Polyn wants to avoid this functionality and instead encourage
25
+ # consumer creation in the centralized `events` codebase so that
26
+ # it's documented, discoverable, and polyn-cli can manage it
27
+ def self.validate_consumer_exists!(nats, stream, consumer_name)
28
+ nats.jetstream.consumer_info(stream, consumer_name)
29
+ rescue NATS::JetStream::Error::NotFound
30
+ raise Polyn::Errors::ValidationError,
31
+ "Consumer #{consumer_name} does not exist. Use polyn-cli to create"\
32
+ "it before attempting to subscribe"
33
+ end
34
+
35
+ # fetch makes a request to be delivered more messages from a pull consumer.
36
+ #
37
+ # @param batch [Fixnum] Number of messages to pull from the stream.
38
+ # @param params [Hash] Options to customize the fetch request.
39
+ # @option params [Float] :timeout Duration of the fetch request before it expires.
40
+ # @return [Array<NATS::Msg>]
41
+ def fetch(batch = 1, params = {})
42
+ msgs = @psub.fetch(batch, params)
43
+ msgs.map do |msg|
44
+ event = Polyn::Serializers::Json.deserialize(@nats, msg.data, store_name: @store_name)
45
+ if event.is_a?(Polyn::Errors::Error)
46
+ msg.term
47
+ raise event
48
+ end
49
+
50
+ msg.data = event
51
+ msg
52
+ end
53
+ end
54
+
55
+ private
56
+
57
+ def store_name(opts)
58
+ opts.fetch(:store_name, Polyn::SchemaStore.store_name)
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Polyn
4
+ ##
5
+ # Persisting and interacting with persisted schemas
6
+ class SchemaStore
7
+ STORE_NAME = "POLYN_SCHEMAS"
8
+
9
+ ##
10
+ # Persist a schema. In prod/dev schemas should have already been persisted via
11
+ # the Polyn CLI.
12
+ def self.save(nats, type, schema, **opts)
13
+ json_schema?(schema)
14
+ kv = nats.jetstream.key_value(store_name(**opts))
15
+ kv.put(type, JSON.generate(schema))
16
+ end
17
+
18
+ def self.json_schema?(schema)
19
+ JSONSchemer.schema(schema)
20
+ end
21
+
22
+ def self.get!(nats, type, **opts)
23
+ result = get(nats, type, **opts)
24
+ raise result if result.is_a?(Polyn::Errors::SchemaError)
25
+
26
+ result
27
+ end
28
+
29
+ def self.get(nats, type, **opts)
30
+ kv = nats.jetstream.key_value(store_name(**opts))
31
+ entry = kv.get(type)
32
+ entry.value
33
+ rescue NATS::KeyValue::BucketNotFoundError
34
+ Polyn::Errors::SchemaError.new(
35
+ "The Schema Store has not been setup on your NATS server. Make sure you use "\
36
+ "the Polyn CLI to create it",
37
+ )
38
+ rescue NATS::JetStream::Error::NotFound
39
+ Polyn::Errors::SchemaError.new(
40
+ "Schema for #{type} does not exist. Make sure it's "\
41
+ "been added to your `events` codebase and has been loaded "\
42
+ "into the schema store on your NATS server",
43
+ )
44
+ end
45
+
46
+ def self.store_name(**opts)
47
+ opts.fetch(:name, STORE_NAME)
48
+ end
49
+ end
50
+ end