polyn 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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