lcp-sdk 1.0.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.
- checksums.yaml +7 -0
- data/README.md +13 -0
- data/lib/generated_models.rb +13 -0
- data/lib/lcp_sdk.rb +94 -0
- data/lib/schema-bundle.json +3230 -0
- data/lib/schema_validator.rb +82 -0
- metadata +70 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 9214c176416eeacd74739b60d6fda64b3c97529a0a94e121ccdfb844235ea9e8
|
|
4
|
+
data.tar.gz: d5cd4a64fde12d0fcd42124e164b3642cad7668ebc84a87a475f4eba701ec824
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: f638e4429a2c1fc51f5252474f86c792f5df11bda57b637cf6961b93ece3bc9bd63b60f78563e50ca5cfad6e98f10c6b24766d5e65963736dcc4e87ee921e518
|
|
7
|
+
data.tar.gz: a5da63d84d8b6169435550838d7a058406673957ade08e19b0c1ce6784c28960bb12a77786adfc16942a1e395bfeaf73c0d0d124c7b66f6b0b50b417f28e5cf6
|
data/README.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# LCP Ruby SDK
|
|
2
|
+
|
|
3
|
+
Tier 2 reference SDK for Ruby 3.0+ publisher, buyer, and SaaS integrations.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
gem build lcp-sdk.gemspec
|
|
7
|
+
ruby test/shared_vector_test.rb
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
The SDK provides generated payload models, `LcpSdk::SchemaValidator` full Draft 2020-12 validation, `LcpSdk.build_envelope`, envelope validation,
|
|
11
|
+
`LcpSdk::Signing` raw-body HMAC helpers, webhook verification, and
|
|
12
|
+
`LcpSdk::Client` operations for leads, calls, bids, status, capabilities, and
|
|
13
|
+
offers. See the shared [SDK contract](../../../docs/SDK-ROADMAP.md).
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# GENERATED FROM schemas/ — do not edit manually.
|
|
2
|
+
module LcpSdk
|
|
3
|
+
MessageModel = Struct.new(:id, :type, :timestamp, :sender_id, :receiver_id, :correlation_id, :idempotency_key, :test, :security, keyword_init: true)
|
|
4
|
+
EnvelopeModel = Struct.new(:version, :message, :payload, keyword_init: true)
|
|
5
|
+
LeadPayload = Struct.new(:lead_id, :status, :channel, :consumer, :location, :attributes, :external_id, :submitted_at, :compliance, :provenance, :exclusivity, :contact_window, :lead_quality, :expiry, :attachments, keyword_init: true)
|
|
6
|
+
CallPayload = Struct.new(:lead_id, :status, :channel, :consumer, :location, :call, :external_id, :submitted_at, :compliance, :provenance, :attributes, :expiry, :exclusivity, :attachments, keyword_init: true)
|
|
7
|
+
PingPayload = Struct.new(:ping_id, :lead_reference, :phone_hash, :country_code, :vertical, :floor_price_cents, :currency, :publisher_id, :offer_id, :email_hash, :attributes, keyword_init: true)
|
|
8
|
+
PostPayload = Struct.new(:lead_id, :delivered_at, :price_cents, :currency, :buyer_id, :consumer, :location, :attributes, :submitted_at, :offer_id, :buyer_reference, :pricing, :compliance, :provenance, :call, :attachments, keyword_init: true)
|
|
9
|
+
BidPayload = Struct.new(:ping_id, :decision, :bid_price_cents, :currency, :estimated_contact_seconds, :buyer_reference, :reject_reason, :capacity_remaining, keyword_init: true)
|
|
10
|
+
AckPayload = Struct.new(:original_message_id, :status, :errors, :lead_id, :request_id, :rejection_reason, keyword_init: true)
|
|
11
|
+
EventPayload = Struct.new(:lead_id, :event, :timestamp, :details, :external_reference, keyword_init: true)
|
|
12
|
+
OfferModel = Struct.new(:offer_id, :buyer_id, :vertical, :countries, :floor_price_cents, :currency, :tenant_id, :active, :routing_mode, :schema_version, :extensions, :allowed_publisher_ids, :allowed_brand_ids, :attribute_equals, :attribute_in, :monthly_minimum_payable, :monthly_maximum_payable, :monthly_quota_timezone, :monthly_quota_policy, :payable_rules, :call_routing_mode, :connect_timeout_seconds, keyword_init: true)
|
|
13
|
+
end
|
data/lib/lcp_sdk.rb
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require "time"
|
|
7
|
+
require "net/http"
|
|
8
|
+
require "uri"
|
|
9
|
+
require_relative "schema_validator"
|
|
10
|
+
require_relative "generated_models"
|
|
11
|
+
|
|
12
|
+
module LcpSdk
|
|
13
|
+
Message = Struct.new(:id, :type, :timestamp, :sender_id, :receiver_id, :correlation_id, :idempotency_key, :test, keyword_init: true)
|
|
14
|
+
|
|
15
|
+
module Signing
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def canonical_bytes(timestamp, idempotency_key, body)
|
|
19
|
+
"#{timestamp}\n#{idempotency_key || ""}\n".b + body.b
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def sign(secret, timestamp, idempotency_key, body)
|
|
23
|
+
OpenSSL::HMAC.hexdigest("SHA256", secret, canonical_bytes(timestamp, idempotency_key, body))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def verify(secret, signature, timestamp, idempotency_key, body, max_skew_seconds: 300, now: Time.now.utc)
|
|
27
|
+
signed_at = Time.iso8601(timestamp)
|
|
28
|
+
raise ArgumentError, "timestamp outside replay window" if (now - signed_at).abs > max_skew_seconds
|
|
29
|
+
expected = sign(secret, timestamp, idempotency_key, body)
|
|
30
|
+
actual = signature.downcase
|
|
31
|
+
raise ArgumentError, "invalid signature" unless expected.bytesize == actual.bytesize && secure_equal?(expected, actual)
|
|
32
|
+
true
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def secure_equal?(left, right)
|
|
36
|
+
difference = 0
|
|
37
|
+
left.bytes.zip(right.bytes) { |a, b| difference |= a ^ b }
|
|
38
|
+
difference.zero?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def verify_request(secret, headers, raw_body, max_skew_seconds: 300, now: Time.now.utc)
|
|
42
|
+
normalized = headers.to_h.transform_keys(&:downcase)
|
|
43
|
+
required = %w[x-lcp-signature x-lcp-timestamp x-lcp-idempotency-key]
|
|
44
|
+
missing = required.reject { |key| normalized[key] && !normalized[key].empty? }
|
|
45
|
+
raise ArgumentError, "missing required LCP headers: #{missing.join(", ")}" unless missing.empty?
|
|
46
|
+
verify(secret, normalized["x-lcp-signature"], normalized["x-lcp-timestamp"], normalized["x-lcp-idempotency-key"], raw_body, max_skew_seconds: max_skew_seconds, now: now)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
module_function
|
|
51
|
+
|
|
52
|
+
def build_envelope(type, sender_id, receiver_id, payload, test: false)
|
|
53
|
+
id = SecureRandom.uuid
|
|
54
|
+
{ "lcp" => { "version" => "1.0.0", "message" => { "id" => id, "type" => type, "timestamp" => Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ"), "sender_id" => sender_id, "receiver_id" => receiver_id, "correlation_id" => nil, "idempotency_key" => "#{sender_id}-#{type}-#{SecureRandom.hex(16)}", "test" => test }, "payload" => payload } }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def validate_envelope(envelope)
|
|
58
|
+
message = envelope.dig("lcp", "message")
|
|
59
|
+
raise ArgumentError, "invalid LCP envelope" unless envelope.dig("lcp", "version").is_a?(String) && message.is_a?(Hash)
|
|
60
|
+
%w[id type timestamp sender_id receiver_id idempotency_key].each { |key| raise ArgumentError, "missing lcp.message.#{key}" unless message[key].is_a?(String) && !message[key].empty? }
|
|
61
|
+
raise ArgumentError, "missing lcp.payload" unless envelope.dig("lcp").key?("payload")
|
|
62
|
+
true
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
class Client
|
|
66
|
+
def initialize(endpoint, sender_id: nil, api_key: nil, hmac_secret: nil, max_retries: 2)
|
|
67
|
+
@endpoint = endpoint.delete_suffix("/"); @sender_id = sender_id; @api_key = api_key; @hmac_secret = hmac_secret; @max_retries = max_retries
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def request(method, path, payload: nil, test: false)
|
|
71
|
+
body = payload.nil? ? "" : JSON.generate(payload)
|
|
72
|
+
key = payload&.dig("lcp", "message", "idempotency_key")
|
|
73
|
+
(0..@max_retries).each do |attempt|
|
|
74
|
+
uri = URI.join("#{@endpoint}/", path.delete_prefix("/")); request = Net::HTTP.const_get(method.capitalize).new(uri)
|
|
75
|
+
request["Content-Type"] = "application/json"; request["Accept"] = "application/json"; request["X-LCP-Sender-Id"] = @sender_id if @sender_id; request["Authorization"] = "Bearer #{@api_key}" if @api_key; request["X-LCP-Idempotency-Key"] = key if key; request["X-LCP-Test"] = "true" if test
|
|
76
|
+
if @hmac_secret; timestamp = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ"); request["X-LCP-Timestamp"] = timestamp; request["X-LCP-Signature"] = Signing.sign(@hmac_secret, timestamp, key, body); end
|
|
77
|
+
request.body = body unless body.empty?
|
|
78
|
+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(request) }
|
|
79
|
+
data = response.body.nil? || response.body.empty? ? {} : JSON.parse(response.body)
|
|
80
|
+
return data if response.is_a?(Net::HTTPSuccess)
|
|
81
|
+
raise "LCP HTTP #{response.code}: #{data}" unless [429, 500, 502, 503, 504].include?(response.code.to_i) && attempt < @max_retries
|
|
82
|
+
sleep(2**attempt)
|
|
83
|
+
end
|
|
84
|
+
raise "LCP request failed"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def submit_lead(envelope, test: false); LcpSdk.validate_envelope(envelope); request("POST", "/v1/lcp/leads", payload: envelope, test: test); end
|
|
88
|
+
def submit_call(envelope, test: false); LcpSdk.validate_envelope(envelope); request("POST", "/v1/lcp/calls", payload: envelope, test: test); end
|
|
89
|
+
def submit_bid(envelope, test: false); LcpSdk.validate_envelope(envelope); request("POST", "/v1/lcp/bids", payload: envelope, test: test); end
|
|
90
|
+
def query_lead_status(id); request("GET", "/v1/lcp/leads/#{URI.encode_www_form_component(id)}"); end
|
|
91
|
+
def get_capabilities; request("GET", "/v1/lcp/capabilities"); end
|
|
92
|
+
def list_offers(vertical = nil); request("GET", "/v1/lcp/offers#{vertical ? "?vertical=#{URI.encode_www_form_component(vertical)}" : ""}"); end
|
|
93
|
+
end
|
|
94
|
+
end
|