morphe_adapter_sdk 1.0.1

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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: b3eeea159d98c6a1db3ad05249f184c498177a79a35f9be08c9b7841f1350411
4
+ data.tar.gz: dde2121058ab5dfee203bd9ee63ade7aca93a7d72915d122dc8191c963d98c1d
5
+ SHA512:
6
+ metadata.gz: aab8206c95bf83c3d9995230c70c7ac181dab1bee7348608f966e2a1e3a494261a8cb529ca09caa21551cf654b254f84205356ce5fa890bd86624ccb22eacb6f
7
+ data.tar.gz: d33bb18d879de995fdcae194b492042d6119c3350835f7375b8dd97c91daf12015126ca9e4202257d5978822f1c9a79ab8579cda0c7d1f7b0aba275735055f0f
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module MorpheAdapterSdk
8
+ # Synchronous client for emitting events to the Morphe Control Plane.
9
+ #
10
+ # Supports:
11
+ # - Optional pre-emit JSON Schema validation (+validate_schema: true+)
12
+ # - HMAC-SHA256 request signing (when +hmac_secret:+ is provided)
13
+ # - Exponential backoff retry on 5xx / network errors
14
+ # - Structured JSON:API error surface (ApiError, SchemaValidationError, NetworkError)
15
+ #
16
+ # @example
17
+ # client = MorpheAdapterSdk::Client.new(token: ENV["MORPHE_TOKEN"])
18
+ # result = client.emit(
19
+ # event_type: "dataops.v1.sync_failed",
20
+ # system_id: "11111111-1111-1111-1111-111111111111",
21
+ # payload: { records_failed: 42 },
22
+ # idempotency_key: "req-abc-123"
23
+ # )
24
+ # puts result.id
25
+ class Client
26
+ DEFAULT_MAX_RETRIES = 3
27
+ DEFAULT_BASE_DELAY = 0.5 # seconds
28
+
29
+ # @!attribute [r] endpoint
30
+ # @return [String] resolved Control Plane base URL
31
+ attr_reader :endpoint
32
+
33
+ # @param token [String] JWT Bearer token
34
+ # @param endpoint [String, nil] override base URL (dev/self-hosted only)
35
+ # @param hmac_secret [String, nil] HMAC-SHA256 shared secret
36
+ # @param max_retries [Integer] default: 3
37
+ # @param base_delay [Float] default: 0.5 s
38
+ # @param validate_schema [Boolean] validate payload locally before emitting
39
+ def initialize(
40
+ token:,
41
+ endpoint: nil,
42
+ hmac_secret: nil,
43
+ max_retries: DEFAULT_MAX_RETRIES,
44
+ base_delay: DEFAULT_BASE_DELAY,
45
+ validate_schema: false
46
+ )
47
+ raw = endpoint || ENV["MORPHE_ENDPOINT"] || DEFAULT_ENDPOINT
48
+ @endpoint = raw.chomp("/")
49
+ @token = token
50
+ @hmac_secret = hmac_secret
51
+ @max_retries = max_retries
52
+ @base_delay = base_delay
53
+ @validate_schema = validate_schema
54
+ @schema_cache = {}
55
+ end
56
+
57
+ # Emit a single event to the Control Plane.
58
+ #
59
+ # If +validate_schema+ was set at construction, the payload is validated
60
+ # against the registered schema before any HTTP request is made.
61
+ # Raises +SchemaValidationError+ on failure.
62
+ #
63
+ # Retries on 5xx responses and network failures with exponential backoff.
64
+ # Raises +ApiError+ on non-retryable HTTP errors (4xx).
65
+ # Raises +NetworkError+ when the Control Plane cannot be reached.
66
+ #
67
+ # @param event [Hash] event payload (symbol or string keys)
68
+ # @return [EmitResult]
69
+ def emit(event)
70
+ event = stringify_keys(event)
71
+
72
+ validate!(event) if @validate_schema
73
+
74
+ MorpheAdapterSdk::Retry.call(
75
+ max_retries: @max_retries,
76
+ base_delay: @base_delay,
77
+ should_retry: ->(err) {
78
+ err.is_a?(NetworkError) ||
79
+ (err.is_a?(ApiError) && err.status >= 500)
80
+ }
81
+ ) { do_request(event) }
82
+ end
83
+
84
+ private
85
+
86
+ EmitResult = Struct.new(:id, :received_at, :schema_valid, :schema_validation_errors, keyword_init: true)
87
+
88
+ def do_request(event)
89
+ body = JSON.generate(event)
90
+ version = MorpheAdapterSdk.extract_api_version(event["event_type"])
91
+ uri = URI.parse("#{@endpoint}/api/#{version}/events")
92
+
93
+ headers = {
94
+ "Content-Type" => "application/json",
95
+ "Authorization" => "Bearer #{@token}"
96
+ }
97
+ headers["X-Morphe-Signature"] = MorpheAdapterSdk::Hmac.sign(body, @hmac_secret) if @hmac_secret
98
+
99
+ response = http_post(uri, body, headers)
100
+
101
+ if response.code.to_i == 202
102
+ data = JSON.parse(response.body)
103
+ attrs = data.dig("data", "attributes")
104
+ return EmitResult.new(
105
+ id: data.dig("data", "id"),
106
+ received_at: attrs["received_at"],
107
+ schema_valid: attrs["schema_valid"],
108
+ schema_validation_errors: attrs["schema_validation_errors"] || []
109
+ )
110
+ end
111
+
112
+ errors = parse_errors(response)
113
+ raise ApiError.new(response.code.to_i, errors)
114
+ end
115
+
116
+ def http_post(uri, body, headers)
117
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 10, read_timeout: 30) do |http|
118
+ req = Net::HTTP::Post.new(uri.request_uri, headers)
119
+ req.body = body
120
+ http.request(req)
121
+ end
122
+ rescue SocketError, Errno::ECONNREFUSED, Errno::ETIMEDOUT, Net::OpenTimeout, Net::ReadTimeout => e
123
+ raise NetworkError.new("Failed to reach the Control Plane", e)
124
+ end
125
+
126
+ def parse_errors(response)
127
+ body = JSON.parse(response.body)
128
+ body["errors"] || []
129
+ rescue JSON::ParserError
130
+ [ { "code" => "parse_error", "title" => "Could not parse error response" } ]
131
+ end
132
+
133
+ def validate!(event)
134
+ require "json-schema"
135
+
136
+ event_type = event["event_type"]
137
+ schema = @schema_cache[event_type] ||= fetch_schema(event_type)
138
+ payload = event["payload"] || {}
139
+
140
+ raw_errors = JSON::Validator.fully_validate(schema, payload)
141
+ return if raw_errors.empty?
142
+
143
+ failures = raw_errors.map { |msg| { field: "/", message: msg } }
144
+ raise SchemaValidationError.new(failures)
145
+ end
146
+
147
+ def fetch_schema(event_type)
148
+ version = MorpheAdapterSdk.extract_api_version(event_type)
149
+ uri = URI.parse("#{@endpoint}/api/#{version}/schemas/#{URI.encode_www_form_component(event_type)}")
150
+ headers = { "Authorization" => "Bearer #{@token}" }
151
+
152
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 10, read_timeout: 30) do |http|
153
+ http.request(Net::HTTP::Get.new(uri.request_uri, headers))
154
+ end
155
+ rescue SocketError, Errno::ECONNREFUSED, Errno::ETIMEDOUT, Net::OpenTimeout, Net::ReadTimeout => e
156
+ raise NetworkError.new("Failed to fetch schema for #{event_type}", e)
157
+ else
158
+ unless (200..299).cover?(response.code.to_i)
159
+ errors = parse_errors(response)
160
+ raise ApiError.new(response.code.to_i, errors.empty? ? [ { "code" => "schema_not_found", "title" => "No schema registered for \"#{event_type}\"" } ] : errors)
161
+ end
162
+
163
+ data = JSON.parse(response.body)
164
+ data.dig("data", "attributes", "schema")
165
+ end
166
+
167
+ def stringify_keys(hash)
168
+ hash.transform_keys(&:to_s)
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MorpheAdapterSdk
4
+ # Base class for all Morphe SDK errors.
5
+ # Callers can rescue MorpheAdapterSdk::Error to catch any SDK error.
6
+ class Error < StandardError; end
7
+
8
+ # Raised when the Control Plane returns a non-2xx response.
9
+ #
10
+ # Exposes the first JSON:API error's +code+, +title+, and +detail+
11
+ # fields as top-level attributes for easy exception handling, plus the
12
+ # full +errors+ array for callers that want all details.
13
+ class ApiError < Error
14
+ attr_reader :status, :code, :title, :detail, :errors
15
+
16
+ # @param status [Integer] HTTP status code
17
+ # @param errors [Array<Hash>] JSON:API errors array
18
+ def initialize(status, errors = [])
19
+ first = errors.first || {}
20
+ @status = status
21
+ @code = first["code"] || "unknown_error"
22
+ @title = first["title"] || "Unknown error"
23
+ @detail = first["detail"]
24
+ @errors = errors
25
+ super("#{@status} #{@title}")
26
+ end
27
+ end
28
+
29
+ # Raised when pre-emit schema validation fails.
30
+ # No HTTP request is made — the payload was rejected locally.
31
+ class SchemaValidationError < Error
32
+ attr_reader :failures
33
+
34
+ # @param failures [Array<Hash>] array of {field:, message:} hashes
35
+ def initialize(failures)
36
+ @failures = failures
37
+ parts = failures.map { |f| "#{f[:field]} #{f[:message]}" }
38
+ super("Schema validation failed: #{parts.join('; ')}")
39
+ end
40
+ end
41
+
42
+ # Raised when a network-level failure prevents reaching the Control Plane.
43
+ # Retries are already exhausted before this is raised.
44
+ class NetworkError < Error
45
+ attr_reader :original_error
46
+
47
+ # @param message [String]
48
+ # @param original_error [Exception]
49
+ def initialize(message, original_error)
50
+ @original_error = original_error
51
+ super(message)
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module MorpheAdapterSdk
6
+ # HMAC-SHA256 signing utility.
7
+ #
8
+ # Produces the value expected by the X-Morphe-Signature header.
9
+ # The result is identical to the TypeScript and Python SDK sign_body
10
+ # implementations when both receive the same UTF-8 encoded inputs.
11
+ module Hmac
12
+ # @param body [String] raw JSON request body (UTF-8)
13
+ # @param secret [String] HMAC shared secret from the API credential
14
+ # @return [String] 64-character lowercase hex digest
15
+ def self.sign(body, secret)
16
+ OpenSSL::HMAC.hexdigest("SHA256", secret, body)
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MorpheAdapterSdk
4
+ # Exponential backoff retry utility.
5
+ #
6
+ # - Configurable +max_retries+ and +base_delay+.
7
+ # - Jitter in [0.5, 1.5] to spread retries across clients.
8
+ # - Injectable +sleep_fn+ for test isolation (avoids stubbing Kernel#sleep).
9
+ module Retry
10
+ # @param max_retries [Integer] maximum retry attempts (0 = no retries)
11
+ # @param base_delay [Float] base delay in seconds before first retry
12
+ # @param should_retry [Proc] predicate: returns true if the error is transient
13
+ # @param sleep_fn [Proc] injectable sleep; defaults to Kernel.method(:sleep)
14
+ # @yield zero-argument block to execute and retry
15
+ # @return the return value of the block on success
16
+ # @raise the last exception after all retries exhausted
17
+ def self.call(max_retries:, base_delay:, should_retry:, sleep_fn: nil, &block)
18
+ sleep_fn ||= method(:sleep)
19
+ attempt = 0
20
+
21
+ loop do
22
+ return yield
23
+ rescue => err
24
+ raise if attempt >= max_retries || !should_retry.call(err)
25
+
26
+ jitter = 0.5 + rand
27
+ delay = base_delay * (2**attempt) * jitter
28
+ sleep_fn.call(delay)
29
+ attempt += 1
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MorpheAdapterSdk
4
+ VERSION = "1.0.0"
5
+
6
+ # Default production endpoint for the Morphe Control Plane.
7
+ # Override via the MORPHE_ENDPOINT environment variable for development
8
+ # or self-hosted deployments.
9
+ DEFAULT_ENDPOINT = "https://api.morphe.io"
10
+
11
+ # Extract the API version segment from an event_type string.
12
+ #
13
+ # The version is the first dot-delimited part matching /^v\d+$/.
14
+ # Falls back to "v1" when no version segment is present.
15
+ #
16
+ # @example
17
+ # MorpheAdapterSdk.extract_api_version("dataops.v1.sync_failed") # => "v1"
18
+ # MorpheAdapterSdk.extract_api_version("dataops.v2.sync_failed") # => "v2"
19
+ # MorpheAdapterSdk.extract_api_version("dataops.sync_failed") # => "v1"
20
+ #
21
+ # @param event_type [String]
22
+ # @return [String]
23
+ def self.extract_api_version(event_type)
24
+ event_type.split(".").find { |part| part.match?(/\Av\d+\z/) } || "v1"
25
+ end
26
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "morphe_adapter_sdk/version"
4
+ require_relative "morphe_adapter_sdk/errors"
5
+ require_relative "morphe_adapter_sdk/hmac"
6
+ require_relative "morphe_adapter_sdk/retry"
7
+ require_relative "morphe_adapter_sdk/client"
8
+
9
+ # MorpheAdapterSdk — Ruby adapter for emitting governance events to the
10
+ # Morphe Control Plane.
11
+ #
12
+ # @example
13
+ # client = MorpheAdapterSdk::Client.new(token: ENV["MORPHE_TOKEN"])
14
+ # result = client.emit(
15
+ # event_type: "dataops.v1.sync_failed",
16
+ # system_id: "11111111-1111-1111-1111-111111111111",
17
+ # payload: { records_failed: 42 }
18
+ # )
19
+ module MorpheAdapterSdk
20
+ end
metadata ADDED
@@ -0,0 +1,104 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: morphe_adapter_sdk
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Morphe
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-03-04 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: json
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '2.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '2.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rspec
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '3.13'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '3.13'
41
+ - !ruby/object:Gem::Dependency
42
+ name: webmock
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.23'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.23'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rack
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '3'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '3'
69
+ description:
70
+ email:
71
+ executables: []
72
+ extensions: []
73
+ extra_rdoc_files: []
74
+ files:
75
+ - lib/morphe_adapter_sdk.rb
76
+ - lib/morphe_adapter_sdk/client.rb
77
+ - lib/morphe_adapter_sdk/errors.rb
78
+ - lib/morphe_adapter_sdk/hmac.rb
79
+ - lib/morphe_adapter_sdk/retry.rb
80
+ - lib/morphe_adapter_sdk/version.rb
81
+ homepage:
82
+ licenses: []
83
+ metadata:
84
+ allowed_push_host: https://rubygems.org
85
+ post_install_message:
86
+ rdoc_options: []
87
+ require_paths:
88
+ - lib
89
+ required_ruby_version: !ruby/object:Gem::Requirement
90
+ requirements:
91
+ - - ">="
92
+ - !ruby/object:Gem::Version
93
+ version: '3.1'
94
+ required_rubygems_version: !ruby/object:Gem::Requirement
95
+ requirements:
96
+ - - ">="
97
+ - !ruby/object:Gem::Version
98
+ version: '0'
99
+ requirements: []
100
+ rubygems_version: 3.5.22
101
+ signing_key:
102
+ specification_version: 4
103
+ summary: Ruby adapter SDK for emitting events to the Morphe Control Plane
104
+ test_files: []