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.
Files changed (63) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +359 -0
  4. data/lib/appnigma/checks.rb +90 -0
  5. data/lib/appnigma/client.rb +118 -0
  6. data/lib/appnigma/connections.rb +31 -0
  7. data/lib/appnigma/constants.rb +80 -0
  8. data/lib/appnigma/crm.rb +40 -0
  9. data/lib/appnigma/errors.rb +135 -0
  10. data/lib/appnigma/generated/api/default_api.rb +972 -0
  11. data/lib/appnigma/generated/api_client.rb +441 -0
  12. data/lib/appnigma/generated/api_error.rb +58 -0
  13. data/lib/appnigma/generated/api_model_base.rb +88 -0
  14. data/lib/appnigma/generated/configuration.rb +392 -0
  15. data/lib/appnigma/generated/models/connection_status.rb +41 -0
  16. data/lib/appnigma/generated/models/create_connection_link_input.rb +265 -0
  17. data/lib/appnigma/generated/models/create_connection_link_output.rb +205 -0
  18. data/lib/appnigma/generated/models/crm_company.rb +322 -0
  19. data/lib/appnigma/generated/models/crm_company_get_output.rb +177 -0
  20. data/lib/appnigma/generated/models/crm_company_list_output.rb +192 -0
  21. data/lib/appnigma/generated/models/crm_contact.rb +404 -0
  22. data/lib/appnigma/generated/models/crm_contact_get_output.rb +177 -0
  23. data/lib/appnigma/generated/models/crm_contact_list_output.rb +192 -0
  24. data/lib/appnigma/generated/models/crm_custom_field.rb +189 -0
  25. data/lib/appnigma/generated/models/crm_deal.rb +479 -0
  26. data/lib/appnigma/generated/models/crm_deal_get_output.rb +177 -0
  27. data/lib/appnigma/generated/models/crm_deal_list_output.rb +192 -0
  28. data/lib/appnigma/generated/models/crm_email.rb +249 -0
  29. data/lib/appnigma/generated/models/crm_get_input.rb +272 -0
  30. data/lib/appnigma/generated/models/crm_labelled.rb +190 -0
  31. data/lib/appnigma/generated/models/crm_list_input.rb +303 -0
  32. data/lib/appnigma/generated/models/crm_phone.rb +249 -0
  33. data/lib/appnigma/generated/models/function_entry.rb +346 -0
  34. data/lib/appnigma/generated/models/function_owner.rb +105 -0
  35. data/lib/appnigma/generated/models/function_owner_one_of.rb +201 -0
  36. data/lib/appnigma/generated/models/function_owner_one_of1.rb +227 -0
  37. data/lib/appnigma/generated/models/get_connection_output.rb +251 -0
  38. data/lib/appnigma/generated/models/get_guide_output.rb +178 -0
  39. data/lib/appnigma/generated/models/json_value.rb +106 -0
  40. data/lib/appnigma/generated/models/list_functions_output.rb +179 -0
  41. data/lib/appnigma/generated/models/platform_request_input.rb +314 -0
  42. data/lib/appnigma/generated/models/platform_request_input_body.rb +104 -0
  43. data/lib/appnigma/generated/models/platform_request_output.rb +221 -0
  44. data/lib/appnigma/generated/models/post_input.rb +327 -0
  45. data/lib/appnigma/generated/models/post_input_record_value.rb +104 -0
  46. data/lib/appnigma/generated/models/post_output.rb +203 -0
  47. data/lib/appnigma/generated/models/pull_input.rb +335 -0
  48. data/lib/appnigma/generated/models/pull_output.rb +191 -0
  49. data/lib/appnigma/generated/models/pull_output_records_inner.rb +205 -0
  50. data/lib/appnigma/generated/models/run_input.rb +332 -0
  51. data/lib/appnigma/generated/models/run_output.rb +164 -0
  52. data/lib/appnigma/generated/models/sdk_error.rb +177 -0
  53. data/lib/appnigma/generated/models/sdk_error_error.rb +254 -0
  54. data/lib/appnigma/generated/models/sdk_error_error_details.rb +211 -0
  55. data/lib/appnigma/generated/models/sdk_problem.rb +205 -0
  56. data/lib/appnigma/generated/version.rb +15 -0
  57. data/lib/appnigma/generated.rb +82 -0
  58. data/lib/appnigma/names.rb +62 -0
  59. data/lib/appnigma/retry.rb +80 -0
  60. data/lib/appnigma/transport.rb +138 -0
  61. data/lib/appnigma/version.rb +9 -0
  62. data/lib/appnigma.rb +16 -0
  63. 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