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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 97bfbad03254a86968687a6f11f10c566b6357bf1294c82521fe8216261586eb
4
- data.tar.gz: 28512438acaea4072288edd364cf246f307abf64cbb2e64eb6decd66f8295b0b
3
+ metadata.gz: a18f4976c060497e06a7fd5af0ee63d14cc080f7541b951d474baefe75c605ae
4
+ data.tar.gz: 9d04564e23b5d62e6a6b848c2c7f6f0e8c4ac08639c41072392dfa95ee4b9664
5
5
  SHA512:
6
- metadata.gz: cdb4c6de628daa762e7c17925d96b51dd4cc26379531de385b61cfcb44fbf8c87c6a87f14b3c104fe4c1f5bd5ffdc953ee16f8ff36a61c0d4a90dfe9c2701ba7
7
- data.tar.gz: 9fd76a38f763fd134758d9b4c89792bd983f707821008dfc9cf4f341fd8a35f53d9455c93c965d20a9649667b076f77f0359d52bc96425966cc4c4770708250d
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