end_point_blank 0.12.0 → 0.13.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 +4 -4
- data/CHANGELOG.md +45 -0
- data/README.md +188 -0
- data/lib/end_point_blank/management/client.rb +228 -0
- data/lib/end_point_blank/management/configuration.rb +56 -0
- data/lib/end_point_blank/management/error.rb +134 -0
- data/lib/end_point_blank/management/error_codes.rb +115 -0
- data/lib/end_point_blank/management/idempotency_key.rb +33 -0
- data/lib/end_point_blank/management/page.rb +55 -0
- data/lib/end_point_blank/management/resources/api_packages.rb +104 -0
- data/lib/end_point_blank/management/resources/applications.rb +167 -0
- data/lib/end_point_blank/management/resources/base.rb +117 -0
- data/lib/end_point_blank/management/resources/clients.rb +152 -0
- data/lib/end_point_blank/management/retry_policy.rb +65 -0
- data/lib/end_point_blank/management/transport.rb +167 -0
- data/lib/end_point_blank/management/url_path.rb +18 -0
- data/lib/end_point_blank/management.rb +52 -0
- data/lib/end_point_blank/version.rb +1 -1
- data/lib/end_point_blank.rb +1 -0
- metadata +15 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a18f4976c060497e06a7fd5af0ee63d14cc080f7541b951d474baefe75c605ae
|
|
4
|
+
data.tar.gz: 9d04564e23b5d62e6a6b848c2c7f6f0e8c4ac08639c41072392dfa95ee4b9664
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 679591ad5e6f06194f5b93f42e30f0c9455deaeaf6b29293a4429beea5cc3e2444d06c7c28389e3432ff8504fbeaa6496ab5e0a08d08f5420888db6d7e0a709e
|
|
7
|
+
data.tar.gz: d52d7a2bf74d7cff868e96bc338cdd8c77e0b4608c61cabd6014efdd5cf90c50b5ba765e990bce9cf53bc7089969a2deacb26aae3edbce285d8cfd0dd5aaffcc
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,50 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.13.0
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **A client for the organization management API (sc-1505).**
|
|
8
|
+
`EndPointBlank::Management::Client` calls app_portal's `/api/v1` so a
|
|
9
|
+
provider can manage its EndPointBlank setup from code instead of
|
|
10
|
+
hand-rolling HTTP calls: the organization (`organization`), API packages
|
|
11
|
+
and what they publish (`api_packages`, `endpoints`), clients and their
|
|
12
|
+
invites (`clients`, including `invite` with pre-assigned `packages` and
|
|
13
|
+
`grants`, and `create_managed`), package assignments
|
|
14
|
+
(`package_assignments`), direct grants (`grants`), `applications` and their
|
|
15
|
+
environments, `environments`, and runtime `credentials` (create and
|
|
16
|
+
`rotate` return the one-time `client_secret`). `for_managed_client(id)`
|
|
17
|
+
gives the same applications, environments and credentials calls for a
|
|
18
|
+
managed client, under `/clients/:client_id/`, plus `claim_invite`.
|
|
19
|
+
|
|
20
|
+
It is plain Ruby (no Rails needed) and separate from the runtime
|
|
21
|
+
configuration: it authenticates only with a management API key, sent as
|
|
22
|
+
`Authorization: Bearer epb_mk_...`, refuses at construction a key without
|
|
23
|
+
that prefix, never sends the runtime `client_id`/`client_secret`, never
|
|
24
|
+
calls intake, and never shows the key in `inspect`, `to_s` or an error
|
|
25
|
+
message. Defaults come from `EndPointBlank::Management.configure` (e.g. a
|
|
26
|
+
Rails initializer) or `ENDPOINTBLANK_MANAGEMENT_KEY` /
|
|
27
|
+
`ENDPOINTBLANK_MANAGEMENT_BASE_URL`, and the base URL defaults to
|
|
28
|
+
`https://app.endpointblank.com`.
|
|
29
|
+
|
|
30
|
+
- Lists answer an `EndPointBlank::Management::Page` (`data`,
|
|
31
|
+
`next_cursor`); each list's `each` walks every page lazily, as an
|
|
32
|
+
`Enumerator` without a block.
|
|
33
|
+
- Every POST sends an `Idempotency-Key` (a random UUID v4 unless you pass
|
|
34
|
+
`idempotency_key:`), and a retry sends the same one.
|
|
35
|
+
- A 429 is retried after its `Retry-After` seconds (1 second without
|
|
36
|
+
one); a 5xx or a request that got no answer is retried with backoff for
|
|
37
|
+
GET, DELETE and POST, never for PATCH; `idempotency_request_in_progress` is retried with the
|
|
38
|
+
same key. At most 2 retries by default (`max_retries:`, `0` turns them
|
|
39
|
+
off), and no single wait longer than `max_retry_wait:` (60 seconds).
|
|
40
|
+
- Every refusal raises `EndPointBlank::Management::Error` (a subclass of
|
|
41
|
+
`EndPointBlank::Error`) with `code`, `message`, `details`, `status`,
|
|
42
|
+
`retry_after`, `location` and `request_id`. `ErrorCodes` lists every
|
|
43
|
+
code the API documents; a code it does not know still raises with that
|
|
44
|
+
code. `idempotency_replay_unavailable` is never retried, and its message
|
|
45
|
+
says to read or list the resource instead.
|
|
46
|
+
- Uses Excon, already a dependency; no new runtime dependency.
|
|
47
|
+
|
|
3
48
|
## 0.12.0
|
|
4
49
|
|
|
5
50
|
### Breaking changes
|
data/README.md
CHANGED
|
@@ -16,6 +16,9 @@ auto-loads (railtie + middleware) when Rails is present.
|
|
|
16
16
|
- **Client-side data masking** (`EndPointBlank::Masking` / `masking_rules`) — strip or redact
|
|
17
17
|
sensitive fields from payloads *before* they leave your process, as defense in depth on top of
|
|
18
18
|
server-side masking.
|
|
19
|
+
- **Management API client** (`EndPointBlank::Management::Client`) — manage API packages, clients,
|
|
20
|
+
grants, applications, environments, credentials and managed clients from code. See
|
|
21
|
+
[Management API](#management-api).
|
|
19
22
|
- **Framework-agnostic core** — `EndPointBlank::Middleware::Rack::ReportInteraction` and the
|
|
20
23
|
writers work directly against Rack env/`::Rack::Request`, so the gem behaves correctly under
|
|
21
24
|
plain Ruby, Sinatra, or any Rack app. When `::Rails` is defined, a `Railtie` auto-inserts the
|
|
@@ -548,6 +551,191 @@ and clears the env store, reporting any raised exception via `ExceptionWriter` a
|
|
|
548
551
|
reads/writes plain Rack request objects (`::Rack::Request`), so it works identically under any
|
|
549
552
|
Rack-compatible server or framework, not only Sinatra.
|
|
550
553
|
|
|
554
|
+
## Management API
|
|
555
|
+
|
|
556
|
+
`EndPointBlank::Management::Client` manages your organization's EndPointBlank setup from code:
|
|
557
|
+
API packages, clients and their invites, package assignments, direct grants, applications,
|
|
558
|
+
environments, runtime credentials, and the managed clients you run for your customers. It calls
|
|
559
|
+
app_portal's management API (`https://app.endpointblank.com/api/v1`). See the
|
|
560
|
+
[guide](https://app.endpointblank.com/docs/management-api) and the
|
|
561
|
+
[reference](https://app.endpointblank.com/docs/management-api-reference).
|
|
562
|
+
|
|
563
|
+
It is plain Ruby, usable from a script, a job or a console as well as a Rails app, and it is
|
|
564
|
+
**separate from the runtime configuration above**. It authenticates only with a management API
|
|
565
|
+
key (create one in the portal under Settings > API Keys), sent as
|
|
566
|
+
`Authorization: Bearer epb_mk_...`. It never sends your runtime `client_id`/`client_secret`, never
|
|
567
|
+
calls intake, and never shows the key in `inspect`, `to_s` or an error message. A key without the
|
|
568
|
+
`epb_mk_` prefix is refused when the client is built, with `EndPointBlank::ConfigurationError`.
|
|
569
|
+
|
|
570
|
+
### Quick start
|
|
571
|
+
|
|
572
|
+
```ruby
|
|
573
|
+
require "end_point_blank"
|
|
574
|
+
|
|
575
|
+
mgmt = EndPointBlank::Management::Client.new(api_key: ENV.fetch("EPB_MGMT_KEY"))
|
|
576
|
+
|
|
577
|
+
mgmt.organization # => {"id" => "...", "name" => "Acme", "slug" => "acme", "key" => {"name" => "ci", "scope" => "write"}, ...}
|
|
578
|
+
|
|
579
|
+
# One page at a time (limit 1..100, default 50) ...
|
|
580
|
+
page = mgmt.applications.list(limit: 20)
|
|
581
|
+
page.data # => [{"id" => "...", "name" => "Orders", ...}, ...]
|
|
582
|
+
page.next_cursor # => pass as `after:` for the next page; nil on the last one
|
|
583
|
+
|
|
584
|
+
# ... or every item, fetching pages as it goes (an Enumerator without a block).
|
|
585
|
+
mgmt.applications.each { |application| puts application["name"] }
|
|
586
|
+
names = mgmt.api_packages.each(limit: 100).map { |package| package["name"] }
|
|
587
|
+
```
|
|
588
|
+
|
|
589
|
+
Every call answers what the API sent, decoded from JSON into Hashes with String keys: the
|
|
590
|
+
resource itself (the response's `data`), a `Page` for a list, and `{"id" => ..., "deleted" => true}`
|
|
591
|
+
for a delete. `api_packages.add_endpoint` and `remove_endpoint` answer the whole body,
|
|
592
|
+
`{"data" => ..., "warnings" => [...]}`, so the `assignment_derives_nothing` warnings are not lost.
|
|
593
|
+
Optional keyword arguments left `nil` are not sent.
|
|
594
|
+
|
|
595
|
+
### Invite a client and assign an API package
|
|
596
|
+
|
|
597
|
+
```ruby
|
|
598
|
+
# Environment names are unique per organization, and "production" is reserved for the one every
|
|
599
|
+
# organization already has; look an existing one up with mgmt.environments.each instead.
|
|
600
|
+
staging = mgmt.environments.create(name: "staging", domain: "staging.example.com")
|
|
601
|
+
orders = mgmt.applications.create(name: "Orders",
|
|
602
|
+
environment_base_urls: { staging["id"] => "https://orders.staging.example.com" })
|
|
603
|
+
|
|
604
|
+
package = mgmt.api_packages.create(name: "Orders read")
|
|
605
|
+
|
|
606
|
+
# An application's endpoints are listed once its runtime SDK has reported them, so a just-created
|
|
607
|
+
# application has none yet. Publish one endpoint when it is there, else the whole application
|
|
608
|
+
# (endpoint_id nil covers every endpoint, including ones reported later).
|
|
609
|
+
endpoint = mgmt.endpoints.each(application_id: orders["id"]).find { |e| e["path"] == "/orders" && e["action"] == "GET" }
|
|
610
|
+
mgmt.api_packages.add_endpoint(package["id"], application_id: orders["id"], endpoint_id: endpoint&.fetch("id"),
|
|
611
|
+
environment_id: staging["id"])
|
|
612
|
+
```
|
|
613
|
+
|
|
614
|
+
Then give a client the package in one of two ways; doing both for the same package and environment
|
|
615
|
+
is refused with `already_assigned`.
|
|
616
|
+
|
|
617
|
+
```ruby
|
|
618
|
+
# Either: set it up on the invite, and it is assigned the moment the client accepts.
|
|
619
|
+
globex = mgmt.clients.invite(
|
|
620
|
+
name: "Globex",
|
|
621
|
+
contacts: [{ email: "dev@globex.example", first_name: "Hank", last_name: "Scorpio" }],
|
|
622
|
+
packages: [{ api_package_id: package["id"], environment_id: staging["id"] }]
|
|
623
|
+
)
|
|
624
|
+
globex["invite_code"] # send this to the client; it accepts from its own EndPointBlank organization
|
|
625
|
+
|
|
626
|
+
# Or: invite first, then assign (pending until the client accepts, active after) and grant directly.
|
|
627
|
+
initrode = mgmt.clients.invite(name: "Initrode")
|
|
628
|
+
mgmt.package_assignments.assign(initrode["id"], api_package_id: package["id"], environment_id: staging["id"])
|
|
629
|
+
mgmt.grants.create(initrode["id"], target_application_id: orders["id"], environment_id: staging["id"])
|
|
630
|
+
```
|
|
631
|
+
|
|
632
|
+
### Runtime credentials
|
|
633
|
+
|
|
634
|
+
```ruby
|
|
635
|
+
app_env = mgmt.applications.list_environments(orders["id"]).first
|
|
636
|
+
credential = mgmt.credentials.create(application_environment_id: app_env["id"])
|
|
637
|
+
credential["client_id"]
|
|
638
|
+
credential["client_secret"] # shown once, here and nowhere else: store it now
|
|
639
|
+
|
|
640
|
+
rotated = mgmt.credentials.rotate(credential["id"])
|
|
641
|
+
rotated["client_secret"] # the new secret; the old one keeps working for the grace window
|
|
642
|
+
|
|
643
|
+
mgmt.credentials.revoke(credential["id"])
|
|
644
|
+
```
|
|
645
|
+
|
|
646
|
+
`list` and `get` answer metadata only (`secret_last_4`, never the secret). This SDK never logs a
|
|
647
|
+
secret, the key, or any request or response body.
|
|
648
|
+
|
|
649
|
+
### Managed clients
|
|
650
|
+
|
|
651
|
+
A managed client is an organization you create and run for a customer until they claim it.
|
|
652
|
+
`for_managed_client(id)` gives the same applications, environments and credentials calls, sent
|
|
653
|
+
under `/api/v1/clients/:client_id/`:
|
|
654
|
+
|
|
655
|
+
```ruby
|
|
656
|
+
customer = mgmt.clients.create_managed(name: "Initech")
|
|
657
|
+
initech = mgmt.for_managed_client(customer["id"])
|
|
658
|
+
|
|
659
|
+
# The managed client's organization already has a "production" environment (the name is
|
|
660
|
+
# reserved); create others alongside it.
|
|
661
|
+
initech_staging = initech.environments.create(name: "staging", domain: "staging.initech.example")
|
|
662
|
+
billing_url = "https://billing.staging.initech.example"
|
|
663
|
+
app = initech.applications.create(name: "Initech billing",
|
|
664
|
+
environment_base_urls: { initech_staging["id"] => billing_url })
|
|
665
|
+
app_env = initech.applications.list_environments(app["id"]).first
|
|
666
|
+
secret = initech.credentials.create(application_environment_id: app_env["id"])["client_secret"]
|
|
667
|
+
|
|
668
|
+
# Grant it your APIs like any accepted client (package and staging from the example above) ...
|
|
669
|
+
mgmt.package_assignments.assign(customer["id"], api_package_id: package["id"], environment_id: staging["id"])
|
|
670
|
+
|
|
671
|
+
# ... and hand it over: the customer gets an email, and claiming rotates every credential you issued.
|
|
672
|
+
initech.claim_invite(email: "it@initech.example")
|
|
673
|
+
```
|
|
674
|
+
|
|
675
|
+
Once claimed, the managed client's calls answer `not_found`. Remove an unclaimed one with
|
|
676
|
+
`mgmt.clients.delete(customer["id"])` after revoking its credentials.
|
|
677
|
+
|
|
678
|
+
### Errors, retries and idempotency
|
|
679
|
+
|
|
680
|
+
Every refusal raises `EndPointBlank::Management::Error` (a subclass of `EndPointBlank::Error`)
|
|
681
|
+
with `code`, `message`, `details`, `status`, `retry_after`, `location` and `request_id`. Match on
|
|
682
|
+
`code`, which is stable; `message` is for people. `EndPointBlank::Management::ErrorCodes` has a
|
|
683
|
+
constant for every code the API documents, and an unknown code still raises with that code.
|
|
684
|
+
|
|
685
|
+
```ruby
|
|
686
|
+
codes = EndPointBlank::Management::ErrorCodes
|
|
687
|
+
|
|
688
|
+
begin
|
|
689
|
+
mgmt.clients.invite(name: "Umbrella")
|
|
690
|
+
rescue EndPointBlank::Management::Error => e
|
|
691
|
+
case e.code
|
|
692
|
+
when codes::PLAN_LIMIT then warn "upgrade your plan to add clients" # 402
|
|
693
|
+
when codes::VALIDATION_FAILED then warn "invalid fields: #{e.details.inspect}" # 422
|
|
694
|
+
when codes::NOT_FOUND then warn "no such resource" # 404
|
|
695
|
+
when codes::INSUFFICIENT_SCOPE then warn "this is a read-only key" # 403
|
|
696
|
+
else raise
|
|
697
|
+
end
|
|
698
|
+
end
|
|
699
|
+
```
|
|
700
|
+
|
|
701
|
+
The SDK raises three codes of its own: `connection_error` (no answer at all; `status` is nil),
|
|
702
|
+
`http_error` (an error answer that is not the API's JSON, e.g. from a proxy) and
|
|
703
|
+
`invalid_response` (a success answer that is not JSON).
|
|
704
|
+
|
|
705
|
+
- **Idempotency.** Every POST sends an `Idempotency-Key`: a random UUID unless you pass
|
|
706
|
+
`idempotency_key:` (1 to 255 characters), and a retry sends the same key, so a POST is never run
|
|
707
|
+
twice. A credential `create` or `rotate` retried after the first one succeeded raises
|
|
708
|
+
`idempotency_replay_unavailable` instead of replaying the secret: read or list the credential
|
|
709
|
+
(rotate it if you never got the secret).
|
|
710
|
+
- **Retries.** A 429 `rate_limited` is retried after its `Retry-After` seconds (1 second
|
|
711
|
+
when it has none). A 5xx
|
|
712
|
+
(`internal_server_error`, `audit_unavailable`, `intake_unavailable`) or a request that got no
|
|
713
|
+
answer is retried with backoff for GET, DELETE and POST, never for PATCH.
|
|
714
|
+
`idempotency_request_in_progress` is retried shortly with the same key. 4xx refusals are never
|
|
715
|
+
retried.
|
|
716
|
+
|
|
717
|
+
| `Client.new` option | Env var fallback | Default | Notes |
|
|
718
|
+
|---|---|---|---|
|
|
719
|
+
| `api_key` | `ENDPOINTBLANK_MANAGEMENT_KEY` | none (required) | A management API key, `epb_mk_...`. |
|
|
720
|
+
| `base_url` | `ENDPOINTBLANK_MANAGEMENT_BASE_URL` | `https://app.endpointblank.com` | app_portal, not intake. |
|
|
721
|
+
| `max_retries` | — | `2` | Retries after the first attempt; `0` turns them off. |
|
|
722
|
+
| `max_retry_wait` | — | `60` | The longest single wait, in seconds; a longer `Retry-After` raises instead. |
|
|
723
|
+
| `connect_timeout` / `read_timeout` | — | `5` / `30` | Seconds. |
|
|
724
|
+
| `sleeper` | — | `Kernel#sleep` | Called with the seconds before each retry (replace it in tests). |
|
|
725
|
+
| `excon_options` | — | `{}` | Extra `Excon.new` options, e.g. a proxy. |
|
|
726
|
+
|
|
727
|
+
In a Rails app, set the defaults once in an initializer, apart from `EndPointBlank.configure`:
|
|
728
|
+
|
|
729
|
+
```ruby
|
|
730
|
+
# config/initializers/end_point_blank_management.rb
|
|
731
|
+
EndPointBlank::Management.configure do |m|
|
|
732
|
+
m.api_key = Rails.application.credentials.dig(:end_point_blank, :management_key)
|
|
733
|
+
m.max_retries = 3
|
|
734
|
+
end
|
|
735
|
+
|
|
736
|
+
EndPointBlank::Management.client.organization # a Client built from that configuration
|
|
737
|
+
```
|
|
738
|
+
|
|
551
739
|
## Development
|
|
552
740
|
|
|
553
741
|
```sh
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
require_relative "configuration"
|
|
5
|
+
require_relative "error"
|
|
6
|
+
require_relative "transport"
|
|
7
|
+
require_relative "resources/base"
|
|
8
|
+
require_relative "resources/api_packages"
|
|
9
|
+
require_relative "resources/clients"
|
|
10
|
+
require_relative "resources/applications"
|
|
11
|
+
|
|
12
|
+
module EndPointBlank
|
|
13
|
+
module Management
|
|
14
|
+
# A client for the EndPointBlank organization management API (app_portal's
|
|
15
|
+
# +/api/v1+): your organization's API packages, clients, package
|
|
16
|
+
# assignments, grants, applications, environments and runtime credentials,
|
|
17
|
+
# and the managed clients you run for your customers.
|
|
18
|
+
#
|
|
19
|
+
# Plain Ruby: usable from a script, a job or a console as well as a Rails
|
|
20
|
+
# app. It authenticates only with a management API key
|
|
21
|
+
# (<tt>Authorization: Bearer epb_mk_...</tt>, created in the portal under
|
|
22
|
+
# Settings > API Keys) and shares nothing with the runtime configuration:
|
|
23
|
+
# it never sends a runtime client_id/client_secret and never calls intake.
|
|
24
|
+
#
|
|
25
|
+
# mgmt = EndPointBlank::Management::Client.new(api_key: ENV.fetch("EPB_MGMT_KEY"))
|
|
26
|
+
# mgmt.organization # => {"id" => ..., "name" => ..., ...}
|
|
27
|
+
# mgmt.api_packages.each { |package| puts package["name"] }
|
|
28
|
+
#
|
|
29
|
+
# Arguments left nil fall back to {Management.configure} (and its
|
|
30
|
+
# ENDPOINTBLANK_MANAGEMENT_* environment variables), then the defaults.
|
|
31
|
+
#
|
|
32
|
+
# Thread-safe: each request opens its own connection.
|
|
33
|
+
class Client
|
|
34
|
+
KEY_PREFIX = "epb_mk_"
|
|
35
|
+
# The prefix and the rest of the key in the alphabet app_portal mints it
|
|
36
|
+
# in (URL-safe base64): nothing else can be a valid key, so nothing else
|
|
37
|
+
# is ever put in a header.
|
|
38
|
+
KEY_FORMAT = /\Aepb_mk_[A-Za-z0-9_-]+\z/.freeze
|
|
39
|
+
|
|
40
|
+
# The options {#initialize} takes besides api_key and base_url.
|
|
41
|
+
OPTIONS = %i[max_retries max_retry_wait connect_timeout read_timeout sleeper excon_options].freeze
|
|
42
|
+
|
|
43
|
+
DEFAULT_SLEEPER = ->(seconds) { Kernel.sleep(seconds) }
|
|
44
|
+
|
|
45
|
+
# @return [Resources::ApiPackages]
|
|
46
|
+
attr_reader :api_packages
|
|
47
|
+
# @return [Resources::Endpoints]
|
|
48
|
+
attr_reader :endpoints
|
|
49
|
+
# @return [Resources::Clients]
|
|
50
|
+
attr_reader :clients
|
|
51
|
+
# @return [Resources::PackageAssignments]
|
|
52
|
+
attr_reader :package_assignments
|
|
53
|
+
# @return [Resources::Grants]
|
|
54
|
+
attr_reader :grants
|
|
55
|
+
# @return [Resources::Applications]
|
|
56
|
+
attr_reader :applications
|
|
57
|
+
# @return [Resources::Environments]
|
|
58
|
+
attr_reader :environments
|
|
59
|
+
# @return [Resources::Credentials]
|
|
60
|
+
attr_reader :credentials
|
|
61
|
+
|
|
62
|
+
# @param api_key [String] a management API key, epb_mk_...
|
|
63
|
+
# @param base_url [String] app_portal's URL (default https://app.endpointblank.com)
|
|
64
|
+
# @param max_retries [Integer] retries after the first attempt; 0 turns them off
|
|
65
|
+
# @param max_retry_wait [Numeric] the longest single wait, in seconds
|
|
66
|
+
# @param connect_timeout [Numeric] seconds
|
|
67
|
+
# @param read_timeout [Numeric] seconds
|
|
68
|
+
# @param sleeper [#call] called with the seconds to wait before a retry
|
|
69
|
+
# (default Kernel#sleep); replace it in tests
|
|
70
|
+
# @param excon_options [Hash] extra options for Excon.new (e.g. a proxy,
|
|
71
|
+
# or <tt>mock: true</tt> with Excon.stub in tests)
|
|
72
|
+
# @raise [EndPointBlank::ConfigurationError] for a missing or malformed key or base URL
|
|
73
|
+
def initialize(api_key: nil, base_url: nil, **options)
|
|
74
|
+
unknown = options.keys - OPTIONS
|
|
75
|
+
raise ArgumentError, "unknown option(s): #{unknown.join(", ")}" unless unknown.empty?
|
|
76
|
+
|
|
77
|
+
config = Management.configuration
|
|
78
|
+
@transport = Transport.new(
|
|
79
|
+
api_key: self.class.validate_key(api_key || config.api_key),
|
|
80
|
+
base_url: self.class.validate_base_url(base_url || config.base_url),
|
|
81
|
+
**transport_options(config, options)
|
|
82
|
+
)
|
|
83
|
+
build_resources
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# app_portal's URL this client calls.
|
|
87
|
+
def base_url
|
|
88
|
+
@transport.base_url
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# GET /organization: the organization the key belongs to, and the key's
|
|
92
|
+
# name and scope.
|
|
93
|
+
# @return [Hash] <tt>{"id", "name", "domain", "slug", "key" => {"name", "scope"}}</tt>
|
|
94
|
+
def organization
|
|
95
|
+
body = @transport.request("GET", "/organization")
|
|
96
|
+
body.is_a?(Hash) ? body["data"] : body
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# A view of the API as your managed client +client_id+ (a client
|
|
100
|
+
# created with <tt>clients.create_managed</tt>): its applications,
|
|
101
|
+
# environments and credentials, under /clients/:client_id/. Works only
|
|
102
|
+
# while the client is unclaimed; afterwards every call answers 404.
|
|
103
|
+
#
|
|
104
|
+
# @return [ManagedClient]
|
|
105
|
+
def for_managed_client(client_id)
|
|
106
|
+
ManagedClient.new(@transport, client_id, @clients)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Never shows the key.
|
|
110
|
+
def inspect
|
|
111
|
+
"#<#{self.class.name} base_url=#{base_url.inspect} api_key=[REDACTED]>"
|
|
112
|
+
end
|
|
113
|
+
alias to_s inspect
|
|
114
|
+
|
|
115
|
+
# The key, if it is a management API key. The error never repeats it.
|
|
116
|
+
# @api private
|
|
117
|
+
def self.validate_key(api_key)
|
|
118
|
+
key = normalized_key(api_key)
|
|
119
|
+
return key if key&.match?(KEY_FORMAT)
|
|
120
|
+
|
|
121
|
+
given = key.nil? || key.empty? ? "No key was given" : "The key given does not have that form"
|
|
122
|
+
raise EndPointBlank::ConfigurationError,
|
|
123
|
+
"EndPointBlank::Management::Client needs a management API key: #{KEY_PREFIX} followed by " \
|
|
124
|
+
"the rest of the key, as the portal shows it under Settings > API Keys. #{given}. " \
|
|
125
|
+
"Runtime client credentials are not accepted by the management API."
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# +api_key+ without surrounding whitespace (a key read from a file or an
|
|
129
|
+
# env var often ends in a newline), or nil when it is not a readable
|
|
130
|
+
# String at all.
|
|
131
|
+
def self.normalized_key(api_key)
|
|
132
|
+
return nil unless api_key.is_a?(String) && api_key.valid_encoding? && api_key.encoding.ascii_compatible?
|
|
133
|
+
|
|
134
|
+
api_key.strip
|
|
135
|
+
end
|
|
136
|
+
private_class_method :normalized_key
|
|
137
|
+
|
|
138
|
+
# +base_url+ without a trailing slash, if it is an http(s) URL with a
|
|
139
|
+
# host and no userinfo, query or fragment. The error never repeats it,
|
|
140
|
+
# since a URL can carry credentials.
|
|
141
|
+
# @api private
|
|
142
|
+
def self.validate_base_url(base_url)
|
|
143
|
+
return UrlPath.strip_trailing_slashes(base_url.to_s) if plain_http_url?(base_url)
|
|
144
|
+
|
|
145
|
+
raise EndPointBlank::ConfigurationError,
|
|
146
|
+
"The management API base_url must be an http(s) URL with a host and no userinfo, " \
|
|
147
|
+
"query or fragment, such as #{Configuration::DEFAULT_BASE_URL}."
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def self.plain_http_url?(base_url)
|
|
151
|
+
uri = URI.parse(base_url.to_s)
|
|
152
|
+
%w[http https].include?(uri.scheme) && !uri.host.to_s.empty? &&
|
|
153
|
+
[uri.userinfo, uri.query, uri.fragment].all?(&:nil?)
|
|
154
|
+
rescue URI::InvalidURIError
|
|
155
|
+
false
|
|
156
|
+
end
|
|
157
|
+
private_class_method :plain_http_url?
|
|
158
|
+
|
|
159
|
+
private
|
|
160
|
+
|
|
161
|
+
def transport_options(config, options)
|
|
162
|
+
{
|
|
163
|
+
max_retries: non_negative(:max_retries, options.fetch(:max_retries, config.max_retries)),
|
|
164
|
+
max_retry_wait: non_negative(:max_retry_wait, options.fetch(:max_retry_wait, config.max_retry_wait)),
|
|
165
|
+
connect_timeout: options.fetch(:connect_timeout, config.connect_timeout),
|
|
166
|
+
read_timeout: options.fetch(:read_timeout, config.read_timeout),
|
|
167
|
+
sleeper: options.fetch(:sleeper, DEFAULT_SLEEPER),
|
|
168
|
+
excon_options: options.fetch(:excon_options, {})
|
|
169
|
+
}
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def non_negative(name, value)
|
|
173
|
+
return value if value.is_a?(Numeric) && !value.negative?
|
|
174
|
+
|
|
175
|
+
raise ArgumentError, "#{name} must be a non-negative number, got #{value.inspect}"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def build_resources
|
|
179
|
+
@api_packages = Resources::ApiPackages.new(@transport)
|
|
180
|
+
@endpoints = Resources::Endpoints.new(@transport)
|
|
181
|
+
@clients = Resources::Clients.new(@transport)
|
|
182
|
+
@package_assignments = Resources::PackageAssignments.new(@transport)
|
|
183
|
+
@grants = Resources::Grants.new(@transport)
|
|
184
|
+
@applications = Resources::Applications.new(@transport)
|
|
185
|
+
@environments = Resources::Environments.new(@transport)
|
|
186
|
+
@credentials = Resources::Credentials.new(@transport)
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Your managed client's applications, environments and credentials, from
|
|
191
|
+
# {Client#for_managed_client}. The same calls as the {Client}'s own,
|
|
192
|
+
# sent under /api/v1/clients/:client_id/.
|
|
193
|
+
class ManagedClient
|
|
194
|
+
# @return [String] the client's id (as in /clients/:client_id)
|
|
195
|
+
attr_reader :client_id
|
|
196
|
+
# @return [Resources::Applications]
|
|
197
|
+
attr_reader :applications
|
|
198
|
+
# @return [Resources::Environments]
|
|
199
|
+
attr_reader :environments
|
|
200
|
+
# @return [Resources::Credentials]
|
|
201
|
+
attr_reader :credentials
|
|
202
|
+
|
|
203
|
+
def initialize(transport, client_id, clients)
|
|
204
|
+
prefix = "/clients/#{Resources::Base.escape(client_id)}"
|
|
205
|
+
@client_id = client_id
|
|
206
|
+
@clients = clients
|
|
207
|
+
@applications = Resources::Applications.new(transport, prefix)
|
|
208
|
+
@environments = Resources::Environments.new(transport, prefix)
|
|
209
|
+
@credentials = Resources::Credentials.new(transport, prefix)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# The client itself (GET /clients/:client_id). @return [Hash]
|
|
213
|
+
def get
|
|
214
|
+
@clients.get(client_id)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# POST /clients/:client_id/claim_invites: emails your customer an
|
|
218
|
+
# invite to claim the client. @return [Hash]
|
|
219
|
+
def claim_invite(email:, idempotency_key: nil)
|
|
220
|
+
@clients.claim_invite(client_id, email: email, idempotency_key: idempotency_key)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def inspect
|
|
224
|
+
"#<#{self.class.name} client_id=#{client_id.inspect}>"
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EndPointBlank
|
|
4
|
+
module Management
|
|
5
|
+
# Defaults for {Client.new}, set with {Management.configure}, e.g. in a
|
|
6
|
+
# Rails initializer.
|
|
7
|
+
#
|
|
8
|
+
# Separate from {EndPointBlank::Configuration} on purpose: the runtime
|
|
9
|
+
# client_id/client_secret are never sent to the management API, and the
|
|
10
|
+
# management key is never sent to intake. Nothing here reads the runtime
|
|
11
|
+
# configuration, and nothing in the runtime configuration reads this.
|
|
12
|
+
class Configuration
|
|
13
|
+
DEFAULT_BASE_URL = "https://app.endpointblank.com"
|
|
14
|
+
DEFAULT_MAX_RETRIES = 2
|
|
15
|
+
DEFAULT_MAX_RETRY_WAIT = 60
|
|
16
|
+
DEFAULT_CONNECT_TIMEOUT = 5
|
|
17
|
+
DEFAULT_READ_TIMEOUT = 30
|
|
18
|
+
|
|
19
|
+
attr_writer :api_key, :base_url
|
|
20
|
+
|
|
21
|
+
# Retries after the first attempt (see {Transport}). 0 turns them off.
|
|
22
|
+
attr_accessor :max_retries
|
|
23
|
+
# The longest wait, in seconds, a retry will sleep (a longer
|
|
24
|
+
# Retry-After raises the error instead).
|
|
25
|
+
attr_accessor :max_retry_wait
|
|
26
|
+
attr_accessor :connect_timeout, :read_timeout
|
|
27
|
+
|
|
28
|
+
def initialize
|
|
29
|
+
@max_retries = DEFAULT_MAX_RETRIES
|
|
30
|
+
@max_retry_wait = DEFAULT_MAX_RETRY_WAIT
|
|
31
|
+
@connect_timeout = DEFAULT_CONNECT_TIMEOUT
|
|
32
|
+
@read_timeout = DEFAULT_READ_TIMEOUT
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The management API key (epb_mk_...), falling back to the
|
|
36
|
+
# ENDPOINTBLANK_MANAGEMENT_KEY environment variable.
|
|
37
|
+
def api_key
|
|
38
|
+
@api_key || ENV.fetch("ENDPOINTBLANK_MANAGEMENT_KEY", nil)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# app_portal's URL, falling back to the
|
|
42
|
+
# ENDPOINTBLANK_MANAGEMENT_BASE_URL environment variable, then
|
|
43
|
+
# {DEFAULT_BASE_URL}. Not the runtime's intake base_url.
|
|
44
|
+
def base_url
|
|
45
|
+
@base_url || ENV.fetch("ENDPOINTBLANK_MANAGEMENT_BASE_URL", nil) || DEFAULT_BASE_URL
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Never shows the key.
|
|
49
|
+
def inspect
|
|
50
|
+
"#<#{self.class.name} base_url=#{base_url.inspect} api_key=#{api_key ? "[REDACTED]" : "nil"} " \
|
|
51
|
+
"max_retries=#{max_retries.inspect}>"
|
|
52
|
+
end
|
|
53
|
+
alias to_s inspect
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|