hivehook 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.
Files changed (34) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +53 -0
  4. data/lib/hivehook/errors.rb +36 -0
  5. data/lib/hivehook/resources/alert_rule_service.rb +44 -0
  6. data/lib/hivehook/resources/api_key_service.rb +34 -0
  7. data/lib/hivehook/resources/application_service.rb +39 -0
  8. data/lib/hivehook/resources/audit_log_service.rb +24 -0
  9. data/lib/hivehook/resources/base_service.rb +22 -0
  10. data/lib/hivehook/resources/bookmark_service.rb +29 -0
  11. data/lib/hivehook/resources/delivery_service.rb +24 -0
  12. data/lib/hivehook/resources/destination_service.rb +44 -0
  13. data/lib/hivehook/resources/dlq_service.rb +39 -0
  14. data/lib/hivehook/resources/endpoint_service.rb +44 -0
  15. data/lib/hivehook/resources/event_service.rb +24 -0
  16. data/lib/hivehook/resources/event_type_schema_service.rb +39 -0
  17. data/lib/hivehook/resources/message_service.rb +39 -0
  18. data/lib/hivehook/resources/organization_service.rb +74 -0
  19. data/lib/hivehook/resources/outbound_delivery_service.rb +24 -0
  20. data/lib/hivehook/resources/outbound_dlq_service.rb +39 -0
  21. data/lib/hivehook/resources/portal_service.rb +12 -0
  22. data/lib/hivehook/resources/source_service.rb +49 -0
  23. data/lib/hivehook/resources/status_service.rb +12 -0
  24. data/lib/hivehook/resources/stream_consumer_service.rb +40 -0
  25. data/lib/hivehook/resources/stream_service.rb +39 -0
  26. data/lib/hivehook/resources/stream_sink_service.rb +40 -0
  27. data/lib/hivehook/resources/subscription_service.rb +39 -0
  28. data/lib/hivehook/resources/transformation_service.rb +44 -0
  29. data/lib/hivehook/resources/user_service.rb +39 -0
  30. data/lib/hivehook/transport.rb +151 -0
  31. data/lib/hivehook/version.rb +5 -0
  32. data/lib/hivehook/webhook.rb +63 -0
  33. data/lib/hivehook.rb +76 -0
  34. metadata +110 -0
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hivehook
4
+ module Resources
5
+ class OutboundDeliveryService < BaseService
6
+ FRAGMENT = "id messageId endpointId status attempts maxAttempts nextAttemptAt createdAt"
7
+
8
+ def list(options = {})
9
+ query = "query($messageId: UUID, $endpointId: UUID, $status: DeliveryStatus, $search: String, $limit: Int, $offset: Int, $after: String, $first: Int) {
10
+ outboundDeliveries(messageId: $messageId, endpointId: $endpointId, status: $status, search: $search, limit: $limit, offset: $offset, after: $after, first: $first) {
11
+ nodes { #{FRAGMENT} }
12
+ pageInfo { total limit offset endCursor hasNextPage }
13
+ }
14
+ }"
15
+ @transport.execute(query, build_variables(options, %w[messageId endpointId status search limit offset after first]))["outboundDeliveries"]
16
+ end
17
+
18
+ def get(id)
19
+ query = "query($id: UUID!) { outboundDelivery(id: $id) { #{FRAGMENT} } }"
20
+ @transport.execute(query, { "id" => id })["outboundDelivery"]
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hivehook
4
+ module Resources
5
+ class OutboundDLQService < BaseService
6
+ FRAGMENT = "id deliveryId messageId lastError replayedAt createdAt"
7
+
8
+ def list(options = {})
9
+ query = "query($messageId: UUID, $replayed: Boolean, $search: String, $limit: Int, $offset: Int, $after: String, $first: Int) {
10
+ outboundDlqEntries(messageId: $messageId, replayed: $replayed, search: $search, limit: $limit, offset: $offset, after: $after, first: $first) {
11
+ nodes { #{FRAGMENT} }
12
+ pageInfo { total limit offset endCursor hasNextPage }
13
+ }
14
+ }"
15
+ @transport.execute(query, build_variables(options, %w[messageId replayed search limit offset after first]))["outboundDlqEntries"]
16
+ end
17
+
18
+ def get(id)
19
+ query = "query($id: UUID!) { outboundDlqEntry(id: $id) { #{FRAGMENT} } }"
20
+ @transport.execute(query, { "id" => id })["outboundDlqEntry"]
21
+ end
22
+
23
+ def replay(id)
24
+ query = "mutation($id: UUID!) { replayOutboundDlqEntry(id: $id) }"
25
+ @transport.execute(query, { "id" => id })["replayOutboundDlqEntry"]
26
+ end
27
+
28
+ def replay_all
29
+ query = "mutation { replayAllOutboundDlq { deliveries } }"
30
+ @transport.execute(query)["replayAllOutboundDlq"]
31
+ end
32
+
33
+ def purge(older_than)
34
+ query = "mutation($olderThan: String!) { purgeOutboundDlq(olderThan: $olderThan) { purged } }"
35
+ @transport.execute(query, { "olderThan" => older_than })["purgeOutboundDlq"]
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hivehook
4
+ module Resources
5
+ class PortalService < BaseService
6
+ def generate_token(application_id)
7
+ query = "mutation($applicationId: UUID!) { generatePortalToken(applicationId: $applicationId) { token expiresAt } }"
8
+ @transport.execute(query, { "applicationId" => application_id })["generatePortalToken"]
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hivehook
4
+ module Resources
5
+ class SourceService < BaseService
6
+ FRAGMENT = "id name slug providerType verifyConfig status rateLimitRps spikeProtection maxIngestRps brokerConfig responseConfig { statusCode body contentType } dedupConfig { strategy fields window } createdAt"
7
+
8
+ def list(options = {})
9
+ query = "query($status: SourceStatus, $providerType: String, $search: String, $limit: Int, $offset: Int, $after: String, $first: Int) {
10
+ sources(status: $status, providerType: $providerType, search: $search, limit: $limit, offset: $offset, after: $after, first: $first) {
11
+ nodes { #{FRAGMENT} }
12
+ pageInfo { total limit offset endCursor hasNextPage }
13
+ }
14
+ }"
15
+ @transport.execute(query, build_variables(options, %w[status providerType search limit offset after first]))["sources"]
16
+ end
17
+
18
+ def get(id)
19
+ query = "query($id: UUID!) { source(id: $id) { #{FRAGMENT} } }"
20
+ @transport.execute(query, { "id" => id })["source"]
21
+ end
22
+
23
+ def create(input)
24
+ query = "mutation($input: CreateSourceInput!) { createSource(input: $input) { #{FRAGMENT} } }"
25
+ @transport.execute(query, { "input" => input })["createSource"]
26
+ end
27
+
28
+ def update(id, input)
29
+ query = "mutation($id: UUID!, $input: UpdateSourceInput!) { updateSource(id: $id, input: $input) { #{FRAGMENT} } }"
30
+ @transport.execute(query, { "id" => id, "input" => input })["updateSource"]
31
+ end
32
+
33
+ def delete(id)
34
+ query = "mutation($id: UUID!) { deleteSource(id: $id) }"
35
+ @transport.execute(query, { "id" => id })["deleteSource"]
36
+ end
37
+
38
+ def rotate_secret(id)
39
+ query = "mutation($id: UUID!) { rotateSourceSecret(id: $id) { #{FRAGMENT} } }"
40
+ @transport.execute(query, { "id" => id })["rotateSourceSecret"]
41
+ end
42
+
43
+ def clear_secondary_secret(id)
44
+ query = "mutation($id: UUID!) { clearSourceSecondarySecret(id: $id) { #{FRAGMENT} } }"
45
+ @transport.execute(query, { "id" => id })["clearSourceSecondarySecret"]
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hivehook
4
+ module Resources
5
+ class StatusService < BaseService
6
+ def get
7
+ query = "query { status { status dlqSize outboundDlqSize queueDepth activeWorkers totalWorkers uptime version sourcesTotal destinationsTotal subscriptionsTotal eventsTotal eventsFailed deliveriesTotal deliveriesPending deliveriesDelivered messagesTotal outboundDeliveriesTotal outboundDeliveriesPending outboundDeliveriesFailed } }"
8
+ @transport.execute(query)["status"]
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hivehook
4
+ module Resources
5
+ class StreamConsumerService < BaseService
6
+ FRAGMENT = "id streamId name cursorSequence createdAt updatedAt"
7
+
8
+ def list(stream_id, options = {})
9
+ query = "query($streamId: UUID!, $search: String, $limit: Int, $offset: Int, $after: String, $first: Int) {
10
+ streamConsumers(streamId: $streamId, search: $search, limit: $limit, offset: $offset, after: $after, first: $first) {
11
+ nodes { #{FRAGMENT} }
12
+ pageInfo { total limit offset endCursor hasNextPage }
13
+ }
14
+ }"
15
+ vars = { "streamId" => stream_id }.merge(build_variables(options, %w[search limit offset after first]))
16
+ @transport.execute(query, vars)["streamConsumers"]
17
+ end
18
+
19
+ def get(id)
20
+ query = "query($id: UUID!) { streamConsumer(id: $id) { #{FRAGMENT} } }"
21
+ @transport.execute(query, { "id" => id })["streamConsumer"]
22
+ end
23
+
24
+ def create(input)
25
+ query = "mutation($input: CreateStreamConsumerInput!) { createStreamConsumer(input: $input) { #{FRAGMENT} } }"
26
+ @transport.execute(query, { "input" => input })["createStreamConsumer"]
27
+ end
28
+
29
+ def delete(id)
30
+ query = "mutation($id: UUID!) { deleteStreamConsumer(id: $id) }"
31
+ @transport.execute(query, { "id" => id })["deleteStreamConsumer"]
32
+ end
33
+
34
+ def advance_cursor(id, sequence)
35
+ query = "mutation($id: UUID!, $sequence: Int!) { advanceConsumerCursor(id: $id, sequence: $sequence) { #{FRAGMENT} } }"
36
+ @transport.execute(query, { "id" => id, "sequence" => sequence })["advanceConsumerCursor"]
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hivehook
4
+ module Resources
5
+ class StreamService < BaseService
6
+ FRAGMENT = "id applicationId name status retentionDays createdAt"
7
+
8
+ def list(options = {})
9
+ query = "query($applicationId: UUID, $status: StreamStatus, $search: String, $limit: Int, $offset: Int, $after: String, $first: Int) {
10
+ streams(applicationId: $applicationId, status: $status, search: $search, limit: $limit, offset: $offset, after: $after, first: $first) {
11
+ nodes { #{FRAGMENT} }
12
+ pageInfo { total limit offset endCursor hasNextPage }
13
+ }
14
+ }"
15
+ @transport.execute(query, build_variables(options, %w[applicationId status search limit offset after first]))["streams"]
16
+ end
17
+
18
+ def get(id)
19
+ query = "query($id: UUID!) { stream(id: $id) { #{FRAGMENT} } }"
20
+ @transport.execute(query, { "id" => id })["stream"]
21
+ end
22
+
23
+ def create(input)
24
+ query = "mutation($input: CreateStreamInput!) { createStream(input: $input) { #{FRAGMENT} } }"
25
+ @transport.execute(query, { "input" => input })["createStream"]
26
+ end
27
+
28
+ def update(id, input)
29
+ query = "mutation($id: UUID!, $input: UpdateStreamInput!) { updateStream(id: $id, input: $input) { #{FRAGMENT} } }"
30
+ @transport.execute(query, { "id" => id, "input" => input })["updateStream"]
31
+ end
32
+
33
+ def delete(id)
34
+ query = "mutation($id: UUID!) { deleteStream(id: $id) }"
35
+ @transport.execute(query, { "id" => id })["deleteStream"]
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hivehook
4
+ module Resources
5
+ class StreamSinkService < BaseService
6
+ FRAGMENT = "id streamId name sinkType config batchSize flushInterval cursorSequence status lastFlushedAt createdAt"
7
+
8
+ def list(stream_id, options = {})
9
+ query = "query($streamId: UUID!, $status: SinkStatus, $search: String, $limit: Int, $offset: Int, $after: String, $first: Int) {
10
+ streamSinks(streamId: $streamId, status: $status, search: $search, limit: $limit, offset: $offset, after: $after, first: $first) {
11
+ nodes { #{FRAGMENT} }
12
+ pageInfo { total limit offset endCursor hasNextPage }
13
+ }
14
+ }"
15
+ vars = { "streamId" => stream_id }.merge(build_variables(options, %w[status search limit offset after first]))
16
+ @transport.execute(query, vars)["streamSinks"]
17
+ end
18
+
19
+ def get(id)
20
+ query = "query($id: UUID!) { streamSink(id: $id) { #{FRAGMENT} } }"
21
+ @transport.execute(query, { "id" => id })["streamSink"]
22
+ end
23
+
24
+ def create(input)
25
+ query = "mutation($input: CreateStreamSinkInput!) { createStreamSink(input: $input) { #{FRAGMENT} } }"
26
+ @transport.execute(query, { "input" => input })["createStreamSink"]
27
+ end
28
+
29
+ def update(id, input)
30
+ query = "mutation($id: UUID!, $input: UpdateStreamSinkInput!) { updateStreamSink(id: $id, input: $input) { #{FRAGMENT} } }"
31
+ @transport.execute(query, { "id" => id, "input" => input })["updateStreamSink"]
32
+ end
33
+
34
+ def delete(id)
35
+ query = "mutation($id: UUID!) { deleteStreamSink(id: $id) }"
36
+ @transport.execute(query, { "id" => id })["deleteStreamSink"]
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hivehook
4
+ module Resources
5
+ class SubscriptionService < BaseService
6
+ FRAGMENT = "id name sourceId destinationId filterConfig enabled createdAt"
7
+
8
+ def list(options = {})
9
+ query = "query($sourceId: UUID, $destinationId: UUID, $enabled: Boolean, $search: String, $limit: Int, $offset: Int, $after: String, $first: Int) {
10
+ subscriptions(sourceId: $sourceId, destinationId: $destinationId, enabled: $enabled, search: $search, limit: $limit, offset: $offset, after: $after, first: $first) {
11
+ nodes { #{FRAGMENT} }
12
+ pageInfo { total limit offset endCursor hasNextPage }
13
+ }
14
+ }"
15
+ @transport.execute(query, build_variables(options, %w[sourceId destinationId enabled search limit offset after first]))["subscriptions"]
16
+ end
17
+
18
+ def get(id)
19
+ query = "query($id: UUID!) { subscription(id: $id) { #{FRAGMENT} } }"
20
+ @transport.execute(query, { "id" => id })["subscription"]
21
+ end
22
+
23
+ def create(input)
24
+ query = "mutation($input: CreateSubscriptionInput!) { createSubscription(input: $input) { #{FRAGMENT} } }"
25
+ @transport.execute(query, { "input" => input })["createSubscription"]
26
+ end
27
+
28
+ def update(id, input)
29
+ query = "mutation($id: UUID!, $input: UpdateSubscriptionInput!) { updateSubscription(id: $id, input: $input) { #{FRAGMENT} } }"
30
+ @transport.execute(query, { "id" => id, "input" => input })["updateSubscription"]
31
+ end
32
+
33
+ def delete(id)
34
+ query = "mutation($id: UUID!) { deleteSubscription(id: $id) }"
35
+ @transport.execute(query, { "id" => id })["deleteSubscription"]
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hivehook
4
+ module Resources
5
+ class TransformationService < BaseService
6
+ FRAGMENT = "id name description code enabled failOpen timeoutMs createdAt updatedAt"
7
+
8
+ def list(options = {})
9
+ query = "query($enabled: Boolean, $search: String, $limit: Int, $offset: Int, $after: String, $first: Int) {
10
+ transformations(enabled: $enabled, search: $search, limit: $limit, offset: $offset, after: $after, first: $first) {
11
+ nodes { #{FRAGMENT} }
12
+ pageInfo { total limit offset endCursor hasNextPage }
13
+ }
14
+ }"
15
+ @transport.execute(query, build_variables(options, %w[enabled search limit offset after first]))["transformations"]
16
+ end
17
+
18
+ def get(id)
19
+ query = "query($id: UUID!) { transformation(id: $id) { #{FRAGMENT} } }"
20
+ @transport.execute(query, { "id" => id })["transformation"]
21
+ end
22
+
23
+ def create(input)
24
+ query = "mutation($input: CreateTransformationInput!) { createTransformation(input: $input) { #{FRAGMENT} } }"
25
+ @transport.execute(query, { "input" => input })["createTransformation"]
26
+ end
27
+
28
+ def update(id, input)
29
+ query = "mutation($id: UUID!, $input: UpdateTransformationInput!) { updateTransformation(id: $id, input: $input) { #{FRAGMENT} } }"
30
+ @transport.execute(query, { "id" => id, "input" => input })["updateTransformation"]
31
+ end
32
+
33
+ def delete(id)
34
+ query = "mutation($id: UUID!) { deleteTransformation(id: $id) }"
35
+ @transport.execute(query, { "id" => id })["deleteTransformation"]
36
+ end
37
+
38
+ def test(input)
39
+ query = "mutation($input: TestTransformationInput!) { testTransformation(input: $input) { success output error durationMs } }"
40
+ @transport.execute(query, { "input" => input })["testTransformation"]
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hivehook
4
+ module Resources
5
+ class UserService < BaseService
6
+ FRAGMENT = "id organizationId email name role lastLoginAt createdAt updatedAt"
7
+
8
+ def list(options = {})
9
+ query = "query($organizationId: UUID, $search: String, $limit: Int, $offset: Int) {
10
+ users(organizationId: $organizationId, search: $search, limit: $limit, offset: $offset) {
11
+ nodes { #{FRAGMENT} }
12
+ pageInfo { total limit offset endCursor hasNextPage }
13
+ }
14
+ }"
15
+ @transport.execute(query, build_variables(options, %w[organizationId search limit offset]))["users"]
16
+ end
17
+
18
+ def me
19
+ query = "query { me { #{FRAGMENT} } }"
20
+ @transport.execute(query, {})["me"]
21
+ end
22
+
23
+ def invite(organization_id, input)
24
+ query = "mutation($organizationId: UUID!, $input: InviteUserInput!) { inviteUser(organizationId: $organizationId, input: $input) { #{FRAGMENT} } }"
25
+ @transport.execute(query, { "organizationId" => organization_id, "input" => input })["inviteUser"]
26
+ end
27
+
28
+ def remove(id)
29
+ query = "mutation($id: UUID!) { removeUser(id: $id) }"
30
+ @transport.execute(query, { "id" => id })["removeUser"]
31
+ end
32
+
33
+ def update_role(id, input)
34
+ query = "mutation($id: UUID!, $input: UpdateUserRoleInput!) { updateUserRole(id: $id, input: $input) { #{FRAGMENT} } }"
35
+ @transport.execute(query, { "id" => id, "input" => input })["updateUserRole"]
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+ require_relative "version"
7
+
8
+ module Hivehook
9
+ class GraphQLTransport
10
+ DEFAULT_OPEN_TIMEOUT = 10
11
+ DEFAULT_READ_TIMEOUT = 30
12
+ DEFAULT_MAX_RETRIES = 2
13
+
14
+ def initialize(base_url, api_key = nil, open_timeout: DEFAULT_OPEN_TIMEOUT,
15
+ read_timeout: DEFAULT_READ_TIMEOUT, max_retries: DEFAULT_MAX_RETRIES)
16
+ @base_url = base_url
17
+ @api_key = api_key
18
+ @open_timeout = open_timeout
19
+ @read_timeout = read_timeout
20
+ @max_retries = max_retries
21
+ end
22
+
23
+ def execute(query, variables = {})
24
+ uri = URI("#{@base_url}/graphql")
25
+
26
+ attempt = 0
27
+ loop do
28
+ response = do_request(uri, query, variables)
29
+ status = response.code.to_i
30
+
31
+ if status == 429
32
+ retry_after = parse_retry_after(response["Retry-After"])
33
+ if attempt < @max_retries
34
+ attempt += 1
35
+ sleep(retry_after || backoff(attempt))
36
+ next
37
+ end
38
+ msg = extract_message(response, "rate limited")
39
+ raise RateLimitError.new(msg, 429, retry_after: retry_after,
40
+ extensions: extract_extensions(response))
41
+ end
42
+
43
+ if status >= 500
44
+ if attempt < @max_retries
45
+ attempt += 1
46
+ sleep(backoff(attempt))
47
+ next
48
+ end
49
+ msg = extract_message(response, "server error")
50
+ raise ServerError.new(msg, status, extensions: extract_extensions(response))
51
+ end
52
+
53
+ return handle_response(response, status)
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ def do_request(uri, query, variables)
60
+ http = Net::HTTP.new(uri.host, uri.port)
61
+ http.use_ssl = uri.scheme == "https"
62
+ http.open_timeout = @open_timeout
63
+ http.read_timeout = @read_timeout
64
+
65
+ request = Net::HTTP::Post.new(uri.path)
66
+ request["Content-Type"] = "application/json"
67
+ request["User-Agent"] = "hivehook-ruby/#{Hivehook::VERSION}"
68
+ request["Authorization"] = "Bearer #{@api_key}" if @api_key
69
+
70
+ request.body = JSON.generate({ query: query, variables: variables })
71
+ http.request(request)
72
+ end
73
+
74
+ def handle_response(response, status)
75
+ if status == 401
76
+ msg = extract_message(response, "unauthorized")
77
+ raise AuthError.new(msg, 401, extensions: extract_extensions(response))
78
+ end
79
+
80
+ if status >= 400
81
+ msg = extract_message(response, response.body)
82
+ raise APIError.new(msg, status, extensions: extract_extensions(response))
83
+ end
84
+
85
+ begin
86
+ json = JSON.parse(response.body)
87
+ rescue JSON::ParserError => e
88
+ raise APIError.new("malformed JSON response: #{e.message}", status)
89
+ end
90
+
91
+ if json["errors"]&.any?
92
+ err = json["errors"][0]
93
+ ext = err["extensions"]
94
+ code = ext.is_a?(Hash) ? ext["code"] : nil
95
+ msg = err["message"]
96
+
97
+ case code
98
+ when "UNAUTHENTICATED", "UNAUTHORIZED"
99
+ raise AuthError.new(msg, 401, extensions: ext, graphql_code: code)
100
+ when "NOT_FOUND"
101
+ raise NotFoundError.new(msg, status, extensions: ext, graphql_code: code)
102
+ when "CONFLICT"
103
+ raise ConflictError.new(msg, status, extensions: ext, graphql_code: code)
104
+ when "VALIDATION", "BAD_USER_INPUT"
105
+ raise ValidationError.new(msg, status, extensions: ext, graphql_code: code)
106
+ else
107
+ raise APIError.new(msg, status, extensions: ext, graphql_code: code)
108
+ end
109
+ end
110
+
111
+ raise APIError.new("empty response data", 500) unless json["data"]
112
+
113
+ json["data"]
114
+ end
115
+
116
+ def extract_message(response, fallback)
117
+ json = JSON.parse(response.body)
118
+ json.dig("errors", 0, "message") || json["message"] || fallback
119
+ rescue JSON::ParserError
120
+ fallback
121
+ end
122
+
123
+ def extract_extensions(response)
124
+ json = JSON.parse(response.body)
125
+ json.dig("errors", 0, "extensions")
126
+ rescue JSON::ParserError
127
+ nil
128
+ end
129
+
130
+ # Parse Retry-After header. RFC 7231 allows seconds or HTTP-date.
131
+ # Returns Float seconds, or nil if unparseable.
132
+ def parse_retry_after(value)
133
+ return nil if value.nil? || value.empty?
134
+ if value =~ /\A\d+(\.\d+)?\z/
135
+ value.to_f
136
+ else
137
+ begin
138
+ t = Time.httpdate(value)
139
+ [t.to_f - Time.now.to_f, 0.0].max
140
+ rescue ArgumentError
141
+ nil
142
+ end
143
+ end
144
+ end
145
+
146
+ def backoff(attempt)
147
+ # 0.1s, 0.2s, 0.4s, ...
148
+ 0.1 * (2**(attempt - 1))
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hivehook
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module Hivehook
6
+ module Webhook
7
+ HEADER_SIGNATURE = "X-Hivehook-Signature"
8
+ HEADER_TIMESTAMP = "X-Hivehook-Timestamp"
9
+ HEADER_MESSAGE_ID = "X-Hivehook-Message-ID"
10
+
11
+ def self.sign(payload, secret, timestamp)
12
+ message = "#{timestamp}.#{payload}"
13
+ digest = OpenSSL::HMAC.hexdigest("SHA256", secret, message)
14
+ "v1=#{digest}"
15
+ end
16
+
17
+ # Verify a webhook signature.
18
+ #
19
+ # tolerance_seconds semantics:
20
+ # nil -> skip timestamp check
21
+ # 0 -> strict, any drift fails
22
+ # positive -> allow past drift up to N seconds, reject future timestamps beyond N seconds
23
+ def self.verify(payload, secret, signature, timestamp, tolerance_seconds = nil)
24
+ unless tolerance_seconds.nil?
25
+ delta = Time.now.to_i - timestamp
26
+ # delta > 0 -> timestamp is in the past
27
+ # delta < 0 -> timestamp is in the future
28
+ if delta > tolerance_seconds || -delta > tolerance_seconds
29
+ return false
30
+ end
31
+ end
32
+
33
+ v1 = extract_v1(signature)
34
+ return false unless v1
35
+
36
+ expected = sign(payload, secret, timestamp)
37
+ secure_compare(expected, v1)
38
+ end
39
+
40
+ def self.verify_with_rotation(payload, primary, secondary, signature, timestamp, tolerance_seconds = nil)
41
+ # Compute both verifications without short-circuiting to keep
42
+ # timing characteristics uniform regardless of which secret matched.
43
+ primary_ok = verify(payload, primary, signature, timestamp, tolerance_seconds)
44
+ secondary_ok = secondary ? verify(payload, secondary, signature, timestamp, tolerance_seconds) : false
45
+ primary_ok | secondary_ok
46
+ end
47
+
48
+ # Parse a multi-scheme signature header value and return the full "v1=..."
49
+ # element, or nil if absent. Supports comma-separated lists like
50
+ # "v1=abc,v2=xyz" or "t=123,v1=abc".
51
+ def self.extract_v1(signature)
52
+ return nil if signature.nil?
53
+ signature.split(",").map(&:strip).find { |part| part.start_with?("v1=") }
54
+ end
55
+
56
+ def self.secure_compare(a, b)
57
+ return false unless a.bytesize == b.bytesize
58
+ OpenSSL.fixed_length_secure_compare(a, b)
59
+ end
60
+
61
+ private_class_method :secure_compare
62
+ end
63
+ end