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

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

Raises:



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.message)
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