Module: ZeroClick::Sellers::Usage
- Defined in:
- lib/zeroclick/sellers/usage.rb
Overview
The two ZeroClick usage endpoints.
Transport and interpretation are deliberately separate: the build_* and interpret_* functions are pure, so every decision about what a response means is testable without a socket, and the client cannot drift from them.
Constant Summary collapse
- CHECK_PATH =
"/v1/usage/check"- REPORT_PATH =
"/v1/usage"- TYPED_REPORT_STATUSES =
report_usage answers these with a typed denial reason rather than an opaque failure, so they are read as JSON instead of treated as noise.
[402, 404, 409].freeze
- TRANSPORT_ERRORS =
Errors that mean the request never got an answer. Anything raised here that is NOT in this list is a bug in the SDK, not an outage, and must not be laundered into a fail-open allow.
[ IOError, SocketError, SystemCallError, Timeout::Error, Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, OpenSSL::SSL::SSLError ].freeze
Class Method Summary collapse
-
.build_check_payload(zc_request_id:, service_slug:, usage:) ⇒ Object
------------------------------------------------------------- build.
- .build_report_payload(zc_agent_id:, idempotency_key:, service_slug:, meter_slug:, quantity:, occurred_at: nil) ⇒ Object
-
.decode(body, operation:, status:) ⇒ Object
--------------------------------------------------------- interpret.
- .interpret_check(status, body) ⇒ Object
- .interpret_report(status, body) ⇒ Object
-
.post(base_url:, path:, api_key:, payload:, timeout:, operation:) ⇒ Object
Returns [status, body].
- .request_headers(api_key) ⇒ Object
Class Method Details
.build_check_payload(zc_request_id:, service_slug:, usage:) ⇒ Object
------------------------------------------------------------- build
50 51 52 53 54 55 56 57 |
# File 'lib/zeroclick/sellers/usage.rb', line 50 def build_check_payload(zc_request_id:, service_slug:, usage:) items = Sellers.normalize_usage(usage, operation: "check_allowance", allow_empty: false) { "zcRequestId" => zc_request_id, "serviceSlug" => service_slug, "usage" => items.map(&:to_wire) } end |
.build_report_payload(zc_agent_id:, idempotency_key:, service_slug:, meter_slug:, quantity:, occurred_at: nil) ⇒ Object
59 60 61 62 63 64 65 66 67 68 69 70 |
# File 'lib/zeroclick/sellers/usage.rb', line 59 def build_report_payload(zc_agent_id:, idempotency_key:, service_slug:, meter_slug:, quantity:, occurred_at: nil) payload = { "zcAgentId" => zc_agent_id, "idempotencyKey" => idempotency_key, "serviceSlug" => service_slug, "meterSlug" => meter_slug, "quantity" => quantity } payload["occurredAt"] = occurred_at unless occurred_at.nil? payload end |
.decode(body, operation:, status:) ⇒ Object
--------------------------------------------------------- interpret
74 75 76 77 78 |
# File 'lib/zeroclick/sellers/usage.rb', line 74 def decode(body, operation:, status:) JSON.parse(body) rescue JSON::ParserError raise Error.new("api_response_invalid", operation: operation, status: status) end |
.interpret_check(status, body) ⇒ Object
80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 |
# File 'lib/zeroclick/sellers/usage.rb', line 80 def interpret_check(status, body) raise Error.new("api_status_error", operation: "check_allowance", status: status) if status >= 400 payload = decode(body, operation: "check_allowance", status: status) unless payload.is_a?(Hash) && [true, false].include?(payload["allowed"]) raise Error.new("api_response_invalid", operation: "check_allowance", status: status) end reason = payload["reason"] if !reason.nil? && !USAGE_DENIAL_REASONS.include?(reason) raise Error.new("api_response_invalid", operation: "check_allowance", status: status, reason: reason) end AllowanceDecision.new(allowed: payload["allowed"], reason: reason) end |
.interpret_report(status, body) ⇒ Object
96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 |
# File 'lib/zeroclick/sellers/usage.rb', line 96 def interpret_report(status, body) if status >= 400 unless TYPED_REPORT_STATUSES.include?(status) raise Error.new("api_status_error", operation: "report_usage", status: status) end payload = decode(body, operation: "report_usage", status: status) reason = payload.is_a?(Hash) ? payload["error"] : nil unless USAGE_DENIAL_REASONS.include?(reason) raise Error.new("api_response_invalid", operation: "report_usage", status: status) end raise Error.new("api_status_error", operation: "report_usage", status: status, reason: reason) end payload = decode(body, operation: "report_usage", status: status) unless payload.is_a?(Hash) && payload["recorded"] == true && [true, false].include?(payload["duplicate"]) && payload["usageEvent"].is_a?(Hash) raise Error.new("api_response_invalid", operation: "report_usage", status: status) end ReportUsageResult.new( recorded: true, duplicate: payload["duplicate"], usage_event: payload["usageEvent"] ) end |
.post(base_url:, path:, api_key:, payload:, timeout:, operation:) ⇒ Object
Returns [status, body]. Raises only api_transport_error — an HTTP status is data here, not a failure, and interpretation happens above.
130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 |
# File 'lib/zeroclick/sellers/usage.rb', line 130 def post(base_url:, path:, api_key:, payload:, timeout:, operation:) uri = URI.join(base_url, path) http = Net::HTTP.new(uri.host, uri.port) http.use_ssl = uri.scheme == "https" # Both halves, or a server that accepts the connection and then stalls # hangs the request past the caller's budget. http.open_timeout = timeout http.read_timeout = timeout http.write_timeout = timeout request = Net::HTTP::Post.new(uri) request_headers(api_key).each { |name, value| request[name] = value } request.body = JSON.generate(payload) response = http.request(request) [response.code.to_i, response.body.to_s] rescue *TRANSPORT_ERRORS => e raise Error.new("api_transport_error", operation: operation, cause: e.) ensure http&.finish if http&.started? end |
.request_headers(api_key) ⇒ Object
40 41 42 43 44 45 46 |
# File 'lib/zeroclick/sellers/usage.rb', line 40 def request_headers(api_key) { "accept" => "application/json", "authorization" => "Bearer #{api_key}", "content-type" => "application/json" } end |