appnigma-sdk 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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +359 -0
- data/lib/appnigma/checks.rb +90 -0
- data/lib/appnigma/client.rb +118 -0
- data/lib/appnigma/connections.rb +31 -0
- data/lib/appnigma/constants.rb +80 -0
- data/lib/appnigma/crm.rb +40 -0
- data/lib/appnigma/errors.rb +135 -0
- data/lib/appnigma/generated/api/default_api.rb +972 -0
- data/lib/appnigma/generated/api_client.rb +441 -0
- data/lib/appnigma/generated/api_error.rb +58 -0
- data/lib/appnigma/generated/api_model_base.rb +88 -0
- data/lib/appnigma/generated/configuration.rb +392 -0
- data/lib/appnigma/generated/models/connection_status.rb +41 -0
- data/lib/appnigma/generated/models/create_connection_link_input.rb +265 -0
- data/lib/appnigma/generated/models/create_connection_link_output.rb +205 -0
- data/lib/appnigma/generated/models/crm_company.rb +322 -0
- data/lib/appnigma/generated/models/crm_company_get_output.rb +177 -0
- data/lib/appnigma/generated/models/crm_company_list_output.rb +192 -0
- data/lib/appnigma/generated/models/crm_contact.rb +404 -0
- data/lib/appnigma/generated/models/crm_contact_get_output.rb +177 -0
- data/lib/appnigma/generated/models/crm_contact_list_output.rb +192 -0
- data/lib/appnigma/generated/models/crm_custom_field.rb +189 -0
- data/lib/appnigma/generated/models/crm_deal.rb +479 -0
- data/lib/appnigma/generated/models/crm_deal_get_output.rb +177 -0
- data/lib/appnigma/generated/models/crm_deal_list_output.rb +192 -0
- data/lib/appnigma/generated/models/crm_email.rb +249 -0
- data/lib/appnigma/generated/models/crm_get_input.rb +272 -0
- data/lib/appnigma/generated/models/crm_labelled.rb +190 -0
- data/lib/appnigma/generated/models/crm_list_input.rb +303 -0
- data/lib/appnigma/generated/models/crm_phone.rb +249 -0
- data/lib/appnigma/generated/models/function_entry.rb +346 -0
- data/lib/appnigma/generated/models/function_owner.rb +105 -0
- data/lib/appnigma/generated/models/function_owner_one_of.rb +201 -0
- data/lib/appnigma/generated/models/function_owner_one_of1.rb +227 -0
- data/lib/appnigma/generated/models/get_connection_output.rb +251 -0
- data/lib/appnigma/generated/models/get_guide_output.rb +178 -0
- data/lib/appnigma/generated/models/json_value.rb +106 -0
- data/lib/appnigma/generated/models/list_functions_output.rb +179 -0
- data/lib/appnigma/generated/models/platform_request_input.rb +314 -0
- data/lib/appnigma/generated/models/platform_request_input_body.rb +104 -0
- data/lib/appnigma/generated/models/platform_request_output.rb +221 -0
- data/lib/appnigma/generated/models/post_input.rb +327 -0
- data/lib/appnigma/generated/models/post_input_record_value.rb +104 -0
- data/lib/appnigma/generated/models/post_output.rb +203 -0
- data/lib/appnigma/generated/models/pull_input.rb +335 -0
- data/lib/appnigma/generated/models/pull_output.rb +191 -0
- data/lib/appnigma/generated/models/pull_output_records_inner.rb +205 -0
- data/lib/appnigma/generated/models/run_input.rb +332 -0
- data/lib/appnigma/generated/models/run_output.rb +164 -0
- data/lib/appnigma/generated/models/sdk_error.rb +177 -0
- data/lib/appnigma/generated/models/sdk_error_error.rb +254 -0
- data/lib/appnigma/generated/models/sdk_error_error_details.rb +211 -0
- data/lib/appnigma/generated/models/sdk_problem.rb +205 -0
- data/lib/appnigma/generated/version.rb +15 -0
- data/lib/appnigma/generated.rb +82 -0
- data/lib/appnigma/names.rb +62 -0
- data/lib/appnigma/retry.rb +80 -0
- data/lib/appnigma/transport.rb +138 -0
- data/lib/appnigma/version.rb +9 -0
- data/lib/appnigma.rb +16 -0
- metadata +155 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 90c492f518f92dda13c8abef25384facbcb6080212be54c0e6df3ac83e157d9a
|
|
4
|
+
data.tar.gz: c0b7f1d24429ac72d1f0131563f9b9291427394bc232a72c7a18d45afd23312e
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 83de48c4ea034b56e10c645f5bd7b524d705938c2ae8086cc0a58f58701d33bd7b6a01749d7fd84651f18eb1d1a99da398a6402aeb9e6b6b7ddc1a99ea2b726b
|
|
7
|
+
data.tar.gz: eb621ed6f68bfefbf5222f6af45415519b7d59e7fffb96cde3f105a371469a9da860b8f5aadc2c7770462d58427429433b73efd8d119af6a4d448bb81bcd4afc
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Appnigma AI Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
# appnigma-sdk
|
|
2
|
+
|
|
3
|
+
The gem your product installs to reach its Appnigma microserver. It sends
|
|
4
|
+
records to an end customer's HubSpot, reads them back, reads contacts,
|
|
5
|
+
companies and deals in one shape whichever CRM they use, runs your own
|
|
6
|
+
functions, calls any platform's API, and connects end customers. One gem for
|
|
7
|
+
every customer. Nothing about your account is inside it, and it holds no
|
|
8
|
+
secret: the key is yours to supply.
|
|
9
|
+
|
|
10
|
+
- Ruby 3.3 or newer. Tested on 3.3, 3.4 and 4.0.
|
|
11
|
+
- The same calls, error codes and retry rules as the TypeScript package
|
|
12
|
+
`@appnigma/sdk`, with names in Ruby style.
|
|
13
|
+
- Not tied to Rails or any framework.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
gem install appnigma-sdk
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Or in your Gemfile:
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
gem "appnigma-sdk"
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## The key
|
|
28
|
+
|
|
29
|
+
Every call carries your SDK key. Appnigma gives you your key when your
|
|
30
|
+
microserver is set up; ask Appnigma for a new one if you need to replace or
|
|
31
|
+
cancel it. It starts with `apn_live_` and is shown once. Keep it on your
|
|
32
|
+
server, in the `APPNIGMA_SDK_KEY` environment variable, never in a browser or
|
|
33
|
+
a repo. The client reads it from there:
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
require "appnigma"
|
|
37
|
+
|
|
38
|
+
client = Appnigma::Client.new
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
If you keep the key somewhere else, pass it in; a passed key wins:
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
client = Appnigma::Client.new(key: secrets.fetch("appnigma-sdk-key"))
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
A client started with no key at all, or with a key that does not start with
|
|
48
|
+
`apn_live_`, raises `Appnigma::AuthError` at once, before any call.
|
|
49
|
+
|
|
50
|
+
## The first call: post a record
|
|
51
|
+
|
|
52
|
+
`post` creates a record or updates the one that already matches. For a
|
|
53
|
+
contact, the match is on `email` unless you name another property with
|
|
54
|
+
`unique_by`. For a company or a deal, `unique_by` is required: name a property
|
|
55
|
+
the platform treats as unique, and include it in `record` with a non-empty
|
|
56
|
+
text value. Because it is an update when the record exists, a repeated call is
|
|
57
|
+
safe.
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
result = client.post(
|
|
61
|
+
end_customer_id: "lawfirm-123",
|
|
62
|
+
platform: "hubspot",
|
|
63
|
+
object: "contact",
|
|
64
|
+
record: { "email" => "jane@firm.com", "firstname" => "Jane", "lastname" => "Doe" }
|
|
65
|
+
)
|
|
66
|
+
puts "#{result[:created] ? "created" : "updated"} #{result[:id]}"
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`end_customer_id` is your own id for your customer, 1 to 100 characters of
|
|
70
|
+
letters, digits, dot, dash or underscore. `platform` is required, and `post`
|
|
71
|
+
and `pull` work on `hubspot` only for now. `object` is `contact`, `company` or
|
|
72
|
+
`deal`. `record` uses the platform's own property names, as strings.
|
|
73
|
+
|
|
74
|
+
## Reading back: pull
|
|
75
|
+
|
|
76
|
+
`pull` returns one page of records and a marker for the next. `next` is `nil`
|
|
77
|
+
on the last page.
|
|
78
|
+
|
|
79
|
+
```ruby
|
|
80
|
+
after = nil
|
|
81
|
+
loop do
|
|
82
|
+
page = client.pull(end_customer_id: "lawfirm-123", platform: "hubspot", object: "contact", limit: 100, after: after)
|
|
83
|
+
page[:records].each { |contact| puts "#{contact[:id]} #{contact[:properties]["email"]}" }
|
|
84
|
+
after = page[:next]
|
|
85
|
+
break if after.nil?
|
|
86
|
+
end
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`limit` is 1 to 100 and defaults to 100.
|
|
90
|
+
|
|
91
|
+
## Connecting an end customer
|
|
92
|
+
|
|
93
|
+
Before calls can work for an end customer, they connect their account on that
|
|
94
|
+
platform once. Any platform switched on for your company can be connected, and
|
|
95
|
+
the end customer approves your own app there. Your product asks for a link,
|
|
96
|
+
shows it to the end customer, and they approve on the platform.
|
|
97
|
+
|
|
98
|
+
1. Ask for a link. It works once and expires after 15 minutes. Opening it does
|
|
99
|
+
not use it up: it is used when the end customer presses Connect, so a mail
|
|
100
|
+
scanner or a link preview opening it first does no harm.
|
|
101
|
+
2. Send the end customer to the link's `url`. They log in to the platform and
|
|
102
|
+
approve.
|
|
103
|
+
3. When they are done, they land on your `return_url` with `status=connected`,
|
|
104
|
+
or with `status=failed` and a `reason`. `return_url` must be HTTPS and at
|
|
105
|
+
most 2048 bytes when written as a JSON string, about 2046 plain characters.
|
|
106
|
+
4. Confirm with `connections.get`. The connection is written before the end
|
|
107
|
+
customer lands, so the answer is already there.
|
|
108
|
+
|
|
109
|
+
```ruby
|
|
110
|
+
link = client.connections.create_link(
|
|
111
|
+
end_customer_id: "lawfirm-123",
|
|
112
|
+
platform: "hubspot",
|
|
113
|
+
return_url: "https://app.example.com/integrations/hubspot/done"
|
|
114
|
+
)
|
|
115
|
+
puts "send the end customer to #{link[:url]} before #{link[:expires_at]}"
|
|
116
|
+
|
|
117
|
+
connection = client.connections.get(end_customer_id: "lawfirm-123", platform: "hubspot")
|
|
118
|
+
puts "ready to post and pull" if connection[:status] == "connected"
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
`status` is `connected`, `needs_reconnect` or `disconnected`. An end customer
|
|
122
|
+
who is already connected can open a new link to connect again, for example
|
|
123
|
+
after changing HubSpot accounts. An end customer the API has never seen is an
|
|
124
|
+
`Appnigma::NotFoundError`, not a status.
|
|
125
|
+
|
|
126
|
+
The id travels in the URL for this call, so an id that is only `.` or `..` is
|
|
127
|
+
refused before the call with `Appnigma::InputError`: a URL drops those, and
|
|
128
|
+
the request would reach the wrong route. Do not use them as end customer ids.
|
|
129
|
+
|
|
130
|
+
## CRM data in one shape: crm
|
|
131
|
+
|
|
132
|
+
`crm.contacts`, `crm.companies` and `crm.deals` read an end customer's CRM in
|
|
133
|
+
one shape, whichever CRM they use. Each has `list` and `get`, and each takes
|
|
134
|
+
`end_customer_id:` and `platform:`.
|
|
135
|
+
|
|
136
|
+
```ruby
|
|
137
|
+
page = client.crm.contacts.list(end_customer_id: "lawfirm-123", platform: "attio", limit: 50)
|
|
138
|
+
page[:records].each { |contact| puts "#{contact[:full_name]} #{contact[:emails].first&.dig(:value)}" }
|
|
139
|
+
|
|
140
|
+
# The next page, while there is one.
|
|
141
|
+
client.crm.contacts.list(end_customer_id: "lawfirm-123", platform: "attio", cursor: page[:next]) if page[:next]
|
|
142
|
+
|
|
143
|
+
deal = client.crm.deals.get(end_customer_id: "lawfirm-123", platform: "hubspot", id: "1234")[:record]
|
|
144
|
+
puts "#{deal[:name]} #{deal[:amount]} #{deal[:currency]} #{deal[:status]}"
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Every field is always there: a value the CRM does not have is `nil`, never
|
|
148
|
+
left out.
|
|
149
|
+
|
|
150
|
+
| Object | Fields |
|
|
151
|
+
| --- | --- |
|
|
152
|
+
| Every record | `id`, `platform`, `created_at`, `updated_at`, `custom_fields`, and `raw`, the record exactly as the CRM sent it |
|
|
153
|
+
| Contact | `first_name`, `last_name`, `full_name`, `emails`, `phones` (the primary one first), `company_id`, `owner_id` |
|
|
154
|
+
| Company | `name`, `domain`, `owner_id` |
|
|
155
|
+
| Deal | `name`, `amount`, `currency`, `status` (`open`, `won` or `lost`), `stage`, `pipeline`, `probability`, `close_date`, `closed_at`, `company_id`, `owner_id` |
|
|
156
|
+
|
|
157
|
+
`custom_fields` is keyed by the CRM's own field names, as strings, and `raw`
|
|
158
|
+
is exactly what the CRM sent. A platform with no unified data yet for that
|
|
159
|
+
object is an `Appnigma::NotAvailableError` with `DATA_TYPE_NOT_AVAILABLE`;
|
|
160
|
+
reach it with `request` instead. An id that does not exist is an
|
|
161
|
+
`Appnigma::NotFoundError` with `RECORD_NOT_FOUND`. Reads are retried like
|
|
162
|
+
`pull`.
|
|
163
|
+
|
|
164
|
+
## Anything else on the platform: request
|
|
165
|
+
|
|
166
|
+
`request` calls the end customer's platform's own API, for anything the shared
|
|
167
|
+
shapes do not cover. It works on every platform switched on for you, including
|
|
168
|
+
ones with no unified data yet.
|
|
169
|
+
|
|
170
|
+
```ruby
|
|
171
|
+
answer = client.request(
|
|
172
|
+
end_customer_id: "lawfirm-123",
|
|
173
|
+
platform: "hubspot",
|
|
174
|
+
method: "GET",
|
|
175
|
+
path: "/crm/v3/objects/notes?limit=10"
|
|
176
|
+
)
|
|
177
|
+
puts answer[:body] if answer[:status] == 200
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
`path` is a path on the platform's own API, with its query: never a full
|
|
181
|
+
address, and it cannot step out of that API, so `..` and `//host` are refused.
|
|
182
|
+
`body` is sent as JSON; leave it out on a `GET`.
|
|
183
|
+
|
|
184
|
+
The answer is the platform's own: its `status`, its `headers` (content type,
|
|
185
|
+
paging and rate limits) and its `body`, parsed from JSON when the platform
|
|
186
|
+
answered JSON and as text otherwise. A refusal from the platform comes back
|
|
187
|
+
the same way, with its status, rather than as an error: check
|
|
188
|
+
`answer[:status]`. A `GET` is retried like `pull`; any other method is made
|
|
189
|
+
once, because only you know whether repeating it is harmless.
|
|
190
|
+
|
|
191
|
+
## Your own functions: catalog
|
|
192
|
+
|
|
193
|
+
Beyond `post` and `pull`, your microserver can hold functions written for you.
|
|
194
|
+
`catalog` lists them, with the platform each one is for, its version, and the
|
|
195
|
+
JSON Schema of its input and output. Leave `platform:` out for every platform,
|
|
196
|
+
or name one to narrow the list.
|
|
197
|
+
|
|
198
|
+
```ruby
|
|
199
|
+
client.catalog(platform: "hubspot")[:functions].each do |function|
|
|
200
|
+
puts "#{function[:platform]} #{function[:name]} v#{function[:version]} #{function[:description]}"
|
|
201
|
+
end
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
A function's `owner` is `{ type: "customer" }` when every end customer can use
|
|
205
|
+
it, or `{ type: "end_customer", end_customer_id: "..." }` when it was written
|
|
206
|
+
for one of them. `input_schema` and `output_schema` are JSON Schema, as
|
|
207
|
+
written. Until a function has gone live for you, the list is empty, not an
|
|
208
|
+
error.
|
|
209
|
+
|
|
210
|
+
## Your own functions: run
|
|
211
|
+
|
|
212
|
+
`run` calls one of them, by platform and name, for one end customer. The input
|
|
213
|
+
is checked against the function's shape before anything runs, so a wrong or
|
|
214
|
+
missing field is an `Appnigma::InputError` listing every problem. Formats and
|
|
215
|
+
patterns, such as an email or a fixed layout for a code, are checked by the
|
|
216
|
+
function itself, so those refusals arrive from it with the same code. The
|
|
217
|
+
answer is `{ output: ... }`, in the shape the function declared, with its keys
|
|
218
|
+
as the function wrote them.
|
|
219
|
+
|
|
220
|
+
```ruby
|
|
221
|
+
result = client.run(
|
|
222
|
+
end_customer_id: "lawfirm-123",
|
|
223
|
+
platform: "hubspot",
|
|
224
|
+
function_name: "invoice-to-deal",
|
|
225
|
+
input: { "invoiceId" => "inv_1042", "amount" => 1250 }
|
|
226
|
+
)
|
|
227
|
+
puts "deal #{result[:output]["dealId"]}"
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
`platform` and `function_name` are both required: the same name can exist on
|
|
231
|
+
two platforms and mean two different things. Leave `version:` out to run the
|
|
232
|
+
oldest version still live, or name one. A name that exists only on another
|
|
233
|
+
platform is an `Appnigma::NotFoundError` whose `details["platforms"]` says
|
|
234
|
+
which. A platform that is not switched on for you is an `Appnigma::AuthError`
|
|
235
|
+
with `PLATFORM_NOT_ENABLED`.
|
|
236
|
+
|
|
237
|
+
`run` is made once and never retried: it is your own function, and only you
|
|
238
|
+
know whether running it twice is harmless. If it fails with a network or
|
|
239
|
+
server error, decide in your code whether to call again.
|
|
240
|
+
|
|
241
|
+
## Your own guide
|
|
242
|
+
|
|
243
|
+
One page, written for your company: install, the key, connecting an end
|
|
244
|
+
customer to each platform switched on for you, `post`, `pull` and `run`, every
|
|
245
|
+
one of your functions with its fields and an example call, and every error
|
|
246
|
+
with what to do about it. `guide` answers it as markdown, built from what is
|
|
247
|
+
live right now.
|
|
248
|
+
|
|
249
|
+
```ruby
|
|
250
|
+
File.write("appnigma-guide.md", client.guide[:guide])
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
## Telling failures apart
|
|
254
|
+
|
|
255
|
+
Every failure is an exception you can rescue by class. All of them inherit
|
|
256
|
+
from `Appnigma::Error`, so one `rescue Appnigma::Error` catches anything from
|
|
257
|
+
Appnigma. Every error carries `code`, `message`, `status` and `request_id`.
|
|
258
|
+
Quote the request id when you write to support@appnigma.ai, and never include
|
|
259
|
+
your key or a token.
|
|
260
|
+
|
|
261
|
+
| Class | When | Extra field |
|
|
262
|
+
| --- | --- | --- |
|
|
263
|
+
| `Appnigma::InputError` | 400. A field is wrong or missing. Also raised locally for a bad setting or a bad end customer id, before any call. | `problems`, a list of `{ field:, problem: }` |
|
|
264
|
+
| `Appnigma::AuthError` | 401 or 403. The key is missing, unknown or cancelled, the microserver is not enabled, or the platform is not switched on for you. Also raised at start with no key. | |
|
|
265
|
+
| `Appnigma::NotFoundError` | 404. No connection for that end customer on that platform, no such function or version, or no such record (`RECORD_NOT_FOUND`). When the function exists on another platform, `details["platforms"]` names it. | |
|
|
266
|
+
| `Appnigma::ReconnectError` | 409 with `CONNECTION_NEEDS_RECONNECT`. The end customer has to approve again. | |
|
|
267
|
+
| `Appnigma::NotAvailableError` | Any other 409. The platform has no unified data for that yet (`DATA_TYPE_NOT_AVAILABLE`); use `request`. | |
|
|
268
|
+
| `Appnigma::RateLimitError` | 429, after the retries. Too many calls, or the platform asked us to slow down. | `retry_after_seconds`, or `nil` when the server named no wait |
|
|
269
|
+
| `Appnigma::ServerError` | Any 5xx, after the retries. Also any answer that is not JSON. | |
|
|
270
|
+
| `Appnigma::NetworkError` | The API could not be reached, or an attempt ran out of time, after the retries. `status` and `request_id` are `nil`. | `cause`, the underlying exception |
|
|
271
|
+
|
|
272
|
+
```ruby
|
|
273
|
+
begin
|
|
274
|
+
client.post(end_customer_id: "lawfirm-123", platform: "hubspot", object: "contact", record: {})
|
|
275
|
+
rescue Appnigma::InputError => e
|
|
276
|
+
e.problems.each { |problem| puts "#{problem[:field]} #{problem[:problem]}" }
|
|
277
|
+
rescue Appnigma::AuthError => e
|
|
278
|
+
puts "check the key: #{e.code}"
|
|
279
|
+
rescue Appnigma::NotFoundError
|
|
280
|
+
puts "this end customer is not connected yet"
|
|
281
|
+
rescue Appnigma::ReconnectError
|
|
282
|
+
puts "ask the end customer to connect again"
|
|
283
|
+
rescue Appnigma::RateLimitError => e
|
|
284
|
+
puts "wait #{e.retry_after_seconds} seconds"
|
|
285
|
+
rescue Appnigma::ServerError => e
|
|
286
|
+
puts "our side failed, quote this id to support: #{e.request_id}"
|
|
287
|
+
rescue Appnigma::NetworkError => e
|
|
288
|
+
puts "could not reach the API: #{e.cause&.message}"
|
|
289
|
+
end
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
Or rescue the base class:
|
|
293
|
+
|
|
294
|
+
```ruby
|
|
295
|
+
begin
|
|
296
|
+
client.connections.get(end_customer_id: "nobody-here", platform: "hubspot")
|
|
297
|
+
rescue Appnigma::Error => e
|
|
298
|
+
puts "#{e.class} #{e.code} #{e.status} #{e.request_id}"
|
|
299
|
+
end
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
`code` is one of the strings in `Appnigma::ERROR_CODES`, or whatever a newer
|
|
303
|
+
server sent. A code this version of the gem does not know is kept as is,
|
|
304
|
+
never replaced.
|
|
305
|
+
|
|
306
|
+
## Retries and time limits
|
|
307
|
+
|
|
308
|
+
A call is tried again only when trying again is safe: the API could not be
|
|
309
|
+
reached, it answered 5xx, or it answered 429. A 400, 401, 403, 404 or 409 is
|
|
310
|
+
never retried, because the same call would be refused the same way. By default
|
|
311
|
+
a call is made up to three times.
|
|
312
|
+
|
|
313
|
+
`connections.create_link`, `run`, and `request` with a method other than `GET`
|
|
314
|
+
are the exceptions: each is made once, never retried. Every call to
|
|
315
|
+
`create_link` makes a new link, and a repeat after a lost answer would leave a
|
|
316
|
+
working link nobody saw; `run` is your own function, and whether a repeat is
|
|
317
|
+
harmless is yours to know. If either fails, decide in your code whether to
|
|
318
|
+
call again.
|
|
319
|
+
|
|
320
|
+
The wait between tries is what the `Retry-After` header says when the server
|
|
321
|
+
sent one, otherwise half a second and then a second, with a little random
|
|
322
|
+
spread so many clients do not retry in the same instant. A `Retry-After`
|
|
323
|
+
longer than `max_retry_wait_ms` is not waited for: the call fails at once with
|
|
324
|
+
an `Appnigma::RateLimitError` that carries the wait, so your code decides what
|
|
325
|
+
to do with half a minute rather than sitting through it.
|
|
326
|
+
|
|
327
|
+
Every attempt has a time limit, 30 seconds by default. An attempt that runs out
|
|
328
|
+
of time counts as a network failure and is retried like one. Each call can set
|
|
329
|
+
its own limit:
|
|
330
|
+
|
|
331
|
+
```ruby
|
|
332
|
+
page = client.pull(end_customer_id: "lawfirm-123", platform: "hubspot", object: "contact", limit: 1, timeout_ms: 10_000)
|
|
333
|
+
puts "one record: #{page[:records].size}"
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
## Settings
|
|
337
|
+
|
|
338
|
+
All passed to `Appnigma::Client.new`, and all optional.
|
|
339
|
+
|
|
340
|
+
| Setting | Default | Meaning |
|
|
341
|
+
| --- | --- | --- |
|
|
342
|
+
| `key:` | the `APPNIGMA_SDK_KEY` environment variable | Your SDK key. |
|
|
343
|
+
| `timeout_ms:` | `30_000` | How long one attempt may take. A whole number of milliseconds, at most 2147483647. |
|
|
344
|
+
| `max_retries:` | `2` | How many times a call is tried again. `0` means one attempt. |
|
|
345
|
+
| `max_retry_wait_ms:` | `30_000` | The longest `Retry-After` the client will sit through, in milliseconds, at most 2147483647. |
|
|
346
|
+
|
|
347
|
+
Per call, as a keyword argument: `timeout_ms:`.
|
|
348
|
+
|
|
349
|
+
## What is exported
|
|
350
|
+
|
|
351
|
+
`Appnigma::Client`, `Appnigma::Error` and its eight subclasses, the constants
|
|
352
|
+
`Appnigma::ERROR_CODES`, `Appnigma::OBJECTS`, `Appnigma::CONNECTION_STATUSES`
|
|
353
|
+
and `Appnigma::PLATFORM_REQUEST_METHODS`, `Appnigma::VERSION`, and
|
|
354
|
+
`Appnigma::API_DESCRIPTION_VERSION`, the version of the published description
|
|
355
|
+
of the API that this version of the gem was generated from.
|
|
356
|
+
|
|
357
|
+
## License
|
|
358
|
+
|
|
359
|
+
MIT. See `LICENSE`.
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
require_relative "constants"
|
|
5
|
+
require_relative "errors"
|
|
6
|
+
|
|
7
|
+
module Appnigma
|
|
8
|
+
# Refusals made here, before any call, for a setting or an input the server
|
|
9
|
+
# would refuse anyway. Same shape as the server's, so one rescue handles
|
|
10
|
+
# both.
|
|
11
|
+
module Checks
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def refuse(field, problem)
|
|
15
|
+
raise InputError.new(
|
|
16
|
+
code: "INVALID_INPUT",
|
|
17
|
+
message: "#{field} #{problem}",
|
|
18
|
+
details: { "problems" => [{ "field" => field, "problem" => problem }] }
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def key!(key)
|
|
23
|
+
if key.nil? || key.empty?
|
|
24
|
+
raise AuthError.new(code: "SDK_KEY_MISSING", message: "No SDK key: pass key:, or set #{KEY_ENV_NAME}")
|
|
25
|
+
end
|
|
26
|
+
unless key.start_with?(KEY_PREFIX)
|
|
27
|
+
raise AuthError.new(code: "SDK_KEY_MISSING",
|
|
28
|
+
message: "That is not an Appnigma SDK key, it starts with #{KEY_PREFIX}")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
key
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def timeout_ms!(value)
|
|
35
|
+
return value if value.is_a?(Integer) && value.between?(1, MAX_TIMER_MS)
|
|
36
|
+
|
|
37
|
+
refuse("timeout_ms", "must be a whole number of milliseconds from 1 to #{MAX_TIMER_MS}")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def max_retries!(value)
|
|
41
|
+
return value if value.is_a?(Integer) && value >= 0
|
|
42
|
+
|
|
43
|
+
refuse("max_retries", "must be a whole number, 0 or more")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def max_retry_wait_ms!(value)
|
|
47
|
+
return value if value.is_a?(Numeric) && value.finite? && value.between?(0, MAX_TIMER_MS)
|
|
48
|
+
|
|
49
|
+
refuse("max_retry_wait_ms", "must be a number of milliseconds from 0 to #{MAX_TIMER_MS}")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def end_customer_id!(value)
|
|
53
|
+
unless value.is_a?(String) && value.match?(END_CUSTOMER_ID_PATTERN)
|
|
54
|
+
refuse("end_customer_id",
|
|
55
|
+
END_CUSTOMER_ID_RULE)
|
|
56
|
+
end
|
|
57
|
+
refuse("end_customer_id", END_CUSTOMER_ID_PATH_RULE) if END_CUSTOMER_ID_PATH_UNSAFE.include?(value)
|
|
58
|
+
value
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def platform!(value)
|
|
62
|
+
return value if value.is_a?(String) && !value.empty?
|
|
63
|
+
|
|
64
|
+
refuse("platform", "must be a platform key, such as hubspot")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# The setting if there is one, else production. An empty setting counts
|
|
68
|
+
# as none. Only https, or http to this machine, so a key never crosses a
|
|
69
|
+
# network unencrypted.
|
|
70
|
+
def base_url!(setting)
|
|
71
|
+
address = (setting.to_s.strip.empty? ? DEFAULT_BASE_URL : setting.strip).sub(%r{/+\z}, "")
|
|
72
|
+
refuse(API_URL_ENV_NAME, "must be an https:// address, or http:// to localhost") unless allowed_address?(address)
|
|
73
|
+
address
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def allowed_address?(address)
|
|
77
|
+
parsed = parse_address(address)
|
|
78
|
+
return false if parsed.nil?
|
|
79
|
+
return !parsed.host.to_s.empty? if parsed.scheme == "https"
|
|
80
|
+
|
|
81
|
+
parsed.scheme == "http" && LOCAL_HOSTS.include?(parsed.host)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def parse_address(address)
|
|
85
|
+
URI.parse(address)
|
|
86
|
+
rescue URI::InvalidURIError
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "constants"
|
|
4
|
+
require_relative "checks"
|
|
5
|
+
require_relative "names"
|
|
6
|
+
require_relative "retry"
|
|
7
|
+
require_relative "transport"
|
|
8
|
+
require_relative "connections"
|
|
9
|
+
require_relative "crm"
|
|
10
|
+
|
|
11
|
+
module Appnigma
|
|
12
|
+
# What a customer creates. The key comes from `key:` or from the
|
|
13
|
+
# APPNIGMA_SDK_KEY environment variable; a client started with no key, or
|
|
14
|
+
# with one that does not start with apn_live_, raises AuthError at once,
|
|
15
|
+
# before any call.
|
|
16
|
+
#
|
|
17
|
+
# Every call takes keyword arguments in Ruby style and answers a Hash with
|
|
18
|
+
# symbol keys in Ruby style, except inside content that is not ours (a
|
|
19
|
+
# record's properties, a function's input and output, a platform's answer),
|
|
20
|
+
# which comes back exactly as it was sent. Every call also takes
|
|
21
|
+
# `timeout_ms:`, the time limit of one attempt.
|
|
22
|
+
class Client
|
|
23
|
+
attr_reader :base_url, :timeout_ms, :max_retries, :max_retry_wait_ms, :connections, :crm
|
|
24
|
+
|
|
25
|
+
# `transport` is for tests; a customer never passes it.
|
|
26
|
+
def initialize(key: nil, timeout_ms: DEFAULT_TIMEOUT_MS, max_retries: DEFAULT_MAX_RETRIES,
|
|
27
|
+
max_retry_wait_ms: DEFAULT_MAX_RETRY_WAIT_MS, transport: nil)
|
|
28
|
+
@key = Checks.key!(key || ENV.fetch(KEY_ENV_NAME, nil))
|
|
29
|
+
@base_url = Checks.base_url!(ENV.fetch(API_URL_ENV_NAME, nil))
|
|
30
|
+
@timeout_ms = Checks.timeout_ms!(timeout_ms)
|
|
31
|
+
@max_retries = Checks.max_retries!(max_retries)
|
|
32
|
+
@max_retry_wait_ms = Checks.max_retry_wait_ms!(max_retry_wait_ms)
|
|
33
|
+
@policy = Retry::Policy.new(max_retries: @max_retries, max_retry_wait_ms: @max_retry_wait_ms)
|
|
34
|
+
# A write whose repeat is not harmless gets one attempt. create_link
|
|
35
|
+
# makes a new link every time; run is your own function, and only you
|
|
36
|
+
# know whether running it twice is harmless; request with a method
|
|
37
|
+
# other than GET may change the platform. post is retried because the
|
|
38
|
+
# server updates the record that matches.
|
|
39
|
+
@one_attempt = Retry::Policy.new(max_retries: 0, max_retry_wait_ms: @max_retry_wait_ms)
|
|
40
|
+
@transport = transport || Transport.new(base_url: @base_url, key: @key)
|
|
41
|
+
@connections = Connections.new(self)
|
|
42
|
+
@crm = Crm.new(self)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Creates a record in the end customer's HubSpot, or updates the one that
|
|
46
|
+
# already matches on `unique_by` (email for a contact unless you name
|
|
47
|
+
# another property; required for a company and a deal).
|
|
48
|
+
def post(end_customer_id:, platform:, object:, record:, unique_by: nil, timeout_ms: nil)
|
|
49
|
+
body = Names.camel_keys(end_customer_id: end_customer_id, platform: platform, object: object, record: record,
|
|
50
|
+
unique_by: unique_by)
|
|
51
|
+
call(:post_record, body, timeout_ms: timeout_ms)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# One page of records and a marker for the next; `next` is nil on the
|
|
55
|
+
# last page. `limit` is 1 to 100.
|
|
56
|
+
def pull(end_customer_id:, platform:, object:, limit: nil, after: nil, timeout_ms: nil)
|
|
57
|
+
body = Names.camel_keys(end_customer_id: end_customer_id, platform: platform, object: object, limit: limit,
|
|
58
|
+
after: after)
|
|
59
|
+
call(:pull_records, body, timeout_ms: timeout_ms)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# One of your own functions, by platform and name. Made once, never
|
|
63
|
+
# retried.
|
|
64
|
+
def run(end_customer_id:, platform:, function_name:, input:, version: nil, timeout_ms: nil)
|
|
65
|
+
body = Names.camel_keys(end_customer_id: end_customer_id, platform: platform, function_name: function_name,
|
|
66
|
+
input: input, version: version)
|
|
67
|
+
call(:run_function, body, policy: @one_attempt, timeout_ms: timeout_ms)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Your own functions, with their shapes. `platform` narrows the list.
|
|
71
|
+
def catalog(platform: nil, timeout_ms: nil)
|
|
72
|
+
call(:list_functions, opts: platform.nil? ? {} : { platform: platform }, timeout_ms: timeout_ms)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# The guide for your developers, as markdown, written for your company
|
|
76
|
+
# from what is live right now.
|
|
77
|
+
def guide(timeout_ms: nil)
|
|
78
|
+
call(:get_guide, timeout_ms: timeout_ms)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Anything else on the end customer's platform, answered as the platform
|
|
82
|
+
# answered, refusals included. A GET is retried like a read; any other
|
|
83
|
+
# method is made once.
|
|
84
|
+
def request(end_customer_id:, platform:, method:, path:, body: nil, timeout_ms: nil)
|
|
85
|
+
payload = Names.camel_keys(end_customer_id: end_customer_id, platform: platform, method: method, path: path,
|
|
86
|
+
body: body)
|
|
87
|
+
call(:platform_request, payload, policy: method == "GET" ? @policy : @one_attempt, timeout_ms: timeout_ms)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# One call: through the transport, under the given retry policy, and
|
|
91
|
+
# into the answer or the error for its status. Used by Connections and
|
|
92
|
+
# Crm as well.
|
|
93
|
+
def call(operation, *args, policy: @policy, timeout_ms: nil, opts: {})
|
|
94
|
+
attempt_ms = timeout_ms.nil? ? @timeout_ms : Checks.timeout_ms!(timeout_ms)
|
|
95
|
+
answer = Retry.with_retries(policy) { @transport.attempt(operation, *args, timeout_ms: attempt_ms, opts: opts) }
|
|
96
|
+
read_answer(answer)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Used by Connections for the one call that is never retried.
|
|
100
|
+
attr_reader :one_attempt
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
# A success whose body is not JSON is a server error: a proxy page or an
|
|
105
|
+
# empty body is never handed back as data.
|
|
106
|
+
def read_answer(answer)
|
|
107
|
+
unless answer[:ok]
|
|
108
|
+
raise Errors.from_answer(status: answer[:status], body: answer[:json], request_id: answer[:request_id],
|
|
109
|
+
retry_after_seconds: answer[:retry_after_seconds])
|
|
110
|
+
end
|
|
111
|
+
if answer[:json].nil?
|
|
112
|
+
raise ServerError.new(code: "INTERNAL_ERROR", status: answer[:status], request_id: answer[:request_id],
|
|
113
|
+
message: "The Appnigma API answered #{answer[:status]} with a body that is not JSON")
|
|
114
|
+
end
|
|
115
|
+
Names.answer(answer[:json])
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "checks"
|
|
4
|
+
require_relative "names"
|
|
5
|
+
|
|
6
|
+
module Appnigma
|
|
7
|
+
# Connecting an end customer to their account on a platform:
|
|
8
|
+
# `client.connections.create_link` and `client.connections.get`.
|
|
9
|
+
class Connections
|
|
10
|
+
def initialize(client)
|
|
11
|
+
@client = client
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# A link to the connect page for one end customer on one platform. It
|
|
15
|
+
# works once and expires after 15 minutes. Made once, never retried:
|
|
16
|
+
# every call makes a new link.
|
|
17
|
+
def create_link(end_customer_id:, platform:, return_url:, timeout_ms: nil)
|
|
18
|
+
body = Names.camel_keys(end_customer_id: end_customer_id, platform: platform, return_url: return_url)
|
|
19
|
+
@client.call(:create_connection_link, body, policy: @client.one_attempt, timeout_ms: timeout_ms)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Whether one end customer is connected on one platform. The id travels
|
|
23
|
+
# in the path, so it is checked here first: a bad one is refused as the
|
|
24
|
+
# server would refuse it, and never becomes a wrong route.
|
|
25
|
+
def get(end_customer_id:, platform:, timeout_ms: nil)
|
|
26
|
+
Checks.end_customer_id!(end_customer_id)
|
|
27
|
+
Checks.platform!(platform)
|
|
28
|
+
@client.call(:get_connection, end_customer_id, platform, timeout_ms: timeout_ms)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|