lingara 0.1.0.pre.alpha.6

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 (69) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +142 -0
  4. data/lib/lingara/access_token.rb +47 -0
  5. data/lib/lingara/client.rb +182 -0
  6. data/lib/lingara/client_credentials.rb +176 -0
  7. data/lib/lingara/deprecation.rb +128 -0
  8. data/lib/lingara/errors.rb +162 -0
  9. data/lib/lingara/event_stream.rb +135 -0
  10. data/lib/lingara/models/allowance_row.rb +378 -0
  11. data/lib/lingara/models/create_lesson_plan_event_error.rb +287 -0
  12. data/lib/lingara/models/create_lesson_plan_event_phase.rb +287 -0
  13. data/lib/lingara/models/create_lesson_plan_event_result.rb +287 -0
  14. data/lib/lingara/models/create_lesson_plan_event_started.rb +287 -0
  15. data/lib/lingara/models/error_envelope.rb +264 -0
  16. data/lib/lingara/models/generate_vocabulary_event_done.rb +287 -0
  17. data/lib/lingara/models/generate_vocabulary_event_error.rb +287 -0
  18. data/lib/lingara/models/generate_vocabulary_event_item.rb +287 -0
  19. data/lib/lingara/models/generate_vocabulary_event_started.rb +287 -0
  20. data/lib/lingara/models/lesson_plan.rb +463 -0
  21. data/lib/lingara/models/lesson_plan_content.rb +305 -0
  22. data/lib/lingara/models/lesson_plan_create_request.rb +333 -0
  23. data/lib/lingara/models/notice.rb +263 -0
  24. data/lib/lingara/models/plan_pending.rb +285 -0
  25. data/lib/lingara/models/plan_phase.rb +281 -0
  26. data/lib/lingara/models/plan_question.rb +337 -0
  27. data/lib/lingara/models/plan_result.rb +237 -0
  28. data/lib/lingara/models/plan_set.rb +293 -0
  29. data/lib/lingara/models/plan_started.rb +237 -0
  30. data/lib/lingara/models/plan_status.rb +42 -0
  31. data/lib/lingara/models/plan_word.rb +273 -0
  32. data/lib/lingara/models/send_tutor_message_event_delta.rb +287 -0
  33. data/lib/lingara/models/send_tutor_message_event_done.rb +287 -0
  34. data/lib/lingara/models/send_tutor_message_event_error.rb +287 -0
  35. data/lib/lingara/models/send_tutor_message_event_notice.rb +287 -0
  36. data/lib/lingara/models/stream_error.rb +273 -0
  37. data/lib/lingara/models/stream_lesson_plan_event_error.rb +287 -0
  38. data/lib/lingara/models/stream_lesson_plan_event_pending.rb +287 -0
  39. data/lib/lingara/models/stream_lesson_plan_event_phase.rb +287 -0
  40. data/lib/lingara/models/stream_lesson_plan_event_result.rb +287 -0
  41. data/lib/lingara/models/stream_lesson_plan_event_started.rb +287 -0
  42. data/lib/lingara/models/turn_delta.rb +237 -0
  43. data/lib/lingara/models/turn_entry.rb +285 -0
  44. data/lib/lingara/models/turn_role.rb +41 -0
  45. data/lib/lingara/models/tutor_turn_request.rb +354 -0
  46. data/lib/lingara/models/usage.rb +250 -0
  47. data/lib/lingara/models/usage_ledger.rb +307 -0
  48. data/lib/lingara/models/version_detail.rb +415 -0
  49. data/lib/lingara/models/version_history_entry.rb +285 -0
  50. data/lib/lingara/models/version_list.rb +263 -0
  51. data/lib/lingara/models/version_spec.rb +249 -0
  52. data/lib/lingara/models/version_state.rb +44 -0
  53. data/lib/lingara/models/version_summary.rb +349 -0
  54. data/lib/lingara/models/vocab_example.rb +263 -0
  55. data/lib/lingara/models/vocab_item.rb +303 -0
  56. data/lib/lingara/models/vocab_meta.rb +394 -0
  57. data/lib/lingara/models/vocab_request.rb +341 -0
  58. data/lib/lingara/models/vocab_started.rb +237 -0
  59. data/lib/lingara/operations.rb +91 -0
  60. data/lib/lingara/response.rb +46 -0
  61. data/lib/lingara/retry.rb +88 -0
  62. data/lib/lingara/sse_decoder.rb +80 -0
  63. data/lib/lingara/streams.rb +104 -0
  64. data/lib/lingara/transport.rb +131 -0
  65. data/lib/lingara/user_agent.rb +24 -0
  66. data/lib/lingara/version.rb +13 -0
  67. data/lib/lingara.rb +23 -0
  68. data/sig/lingara.rbs +151 -0
  69. metadata +114 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 9900f36d73945227ac18de4de3520240ed8be150dc9db270802d55d5a858d60d
4
+ data.tar.gz: 84897db9f327ec33dc6823a11b2489a29e5da845f86f0c7be8ed2b2b87fdbbc4
5
+ SHA512:
6
+ metadata.gz: d831190d39cc8a6d840d913764c06ef40129999b2908237cfbb1f1c7529f93a894883d15925a55a0006d321153beecf78c03c075ee466bc4380ddfc93c984453
7
+ data.tar.gz: 683926e460ce43175de4fb365168726f61a0d145e0fc6f80dd2f482ca9171b624a633accd4934c0c8dd4b2cc0459a8f4663b181cfd8612c4b337514974d06228
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Spinning Cat Studios
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,142 @@
1
+ # lingara (Ruby)
2
+
3
+ The official Ruby library for the [Lingara API](https://getlingara.com). It is
4
+ built on Ruby's standard library alone: `net/http`, `openssl` and `json`. The
5
+ gem has no runtime dependency, so `gem install lingara` fetches one gem and
6
+ nothing else.
7
+
8
+ This library is for your server. The Lingara API authenticates with a client
9
+ secret; the [authentication guide](https://getlingara.com/guides/authentication)
10
+ explains why credentials stay on a server.
11
+
12
+ ## Install
13
+
14
+ ```sh
15
+ gem install lingara
16
+ ```
17
+
18
+ or, with Bundler, `gem "lingara"` in your `Gemfile`. **Ruby 3.3 or newer**: the
19
+ floor is the oldest Ruby branch still in upstream maintenance.
20
+
21
+ ## Quick start
22
+
23
+ ```ruby
24
+ require "lingara"
25
+
26
+ client = Lingara::Client.new(
27
+ client_id: ENV.fetch("LINGARA_CLIENT_ID"),
28
+ client_secret: ENV.fetch("LINGARA_CLIENT_SECRET"),
29
+ # version: "2026-09-knowing-tenpounder", # optional pin
30
+ )
31
+
32
+ client.generate_vocabulary(level: 2, source_lang: "en", target_lang: "zh", count: 8) do |event|
33
+ case event
34
+ in Lingara::GenerateVocabularyEventItem => item then puts "#{item.data.word}: #{item.data.translation}"
35
+ else nil
36
+ end
37
+ end
38
+
39
+ usage = client.get_usage # Lingara::Response
40
+ p usage.value.allowance, usage.served_version
41
+ ```
42
+
43
+ Nine methods, each the `operationId` in snake_case: `generate_vocabulary`,
44
+ `create_lesson_plan`, `get_lesson_plan`, `stream_lesson_plan`,
45
+ `send_tutor_message`, `get_usage`, `get_open_api_document`,
46
+ `list_api_versions` and `get_api_version`. A path parameter is a positional
47
+ `String` (`client.get_lesson_plan(plan_id)`); a request body is keyword
48
+ arguments, and an unknown or missing keyword raises `ArgumentError` before any
49
+ request is sent.
50
+
51
+ A JSON method returns a `Lingara::Response`: `#value` is the generated model
52
+ (a `Hash` for `get_open_api_document`) and `#served_version` is the
53
+ `Lingara-Version` the server answered under, or `nil`.
54
+
55
+ `get_open_api_document`, `list_api_versions` and `get_api_version` need no
56
+ token, so `Lingara::Client.new` with no credentials calls them. A client
57
+ without credentials sends the other six without `Authorization`, and the
58
+ server's `401` is the answer.
59
+
60
+ ## Streams
61
+
62
+ The four streaming methods take a block, or return a stream:
63
+
64
+ - **With a block**, the call sends the request, yields each event and returns
65
+ a `Lingara::StreamResult` (`#served_version`). `break`, `return` or `throw`
66
+ out of the block closes the connection.
67
+ - **Without a block**, the call returns a `Lingara::EventStream`, which is
68
+ `Enumerable`. **The request is sent lazily**, on the first `each` or
69
+ `next`, so a refusal surfaces there rather than at the call, and a stream
70
+ you never iterate holds no connection. It is **single-use**: a second
71
+ `each` raises `IOError`. After external iteration with `next`, call
72
+ `#close` if you stop before the end; the block form needs no `close`.
73
+
74
+ ```ruby
75
+ stream = client.stream_lesson_plan(plan_id)
76
+ begin
77
+ first = stream.next
78
+ ensure
79
+ stream.close
80
+ end
81
+ ```
82
+
83
+ Each event is a generated branch class (`Lingara::GenerateVocabularyEventItem`
84
+ has `#event` and `#data`), and `in Lingara::GenerateVocabularyEvent` matches
85
+ any of that stream's branches. Which events end a stream is the spec's: the
86
+ stream ends after `result` or `pending`, which are yielded, and after `done`,
87
+ which is not. An `error` event is never yielded: it raises
88
+ `Lingara::ApiError` with `status` 200 and, for a lesson plan, `plan_id`.
89
+
90
+ A stream fails with `Lingara::TransportError` (`kind: :timeout`) after
91
+ `stream_idle_timeout:` seconds (120 by default) in which no byte arrives
92
+ while the library is waiting for your next event. Time you spend holding an
93
+ event never counts.
94
+
95
+ ## Errors
96
+
97
+ Every failure is a `Lingara::Error`, one of four:
98
+
99
+ | Class | When | Fields |
100
+ |---|---|---|
101
+ | `Lingara::ApiError` | a `/v1` refusal, or a stream's `error` event | `status`, `code`, `message`, `retry_after`, `plan_id`, `served_version` |
102
+ | `Lingara::OAuthError` | the token endpoint refused | `status`, `error`, `description`, `retry_after` |
103
+ | `Lingara::MaintenanceError` | the API is in maintenance | `body`, `retry_after` |
104
+ | `Lingara::TransportError` | no usable answer | `kind`: `:connect`, `:tls`, `:reset`, `:timeout`, `:stream_ended_early`, `:malformed_response`, `:malformed_event` |
105
+
106
+ A `429` or `503` with a `Retry-After` of at most `retry_after_cap:` (60 s) is
107
+ retried, up to `max_attempts:` (3) tries; a `401` fetches a fresh token and
108
+ retries once. A stream that has yielded an event is never replayed. Your own
109
+ interrupt (`Thread#raise`, `Thread#kill`, `Timeout.timeout`) is never wrapped
110
+ and never retried.
111
+
112
+ ## Options
113
+
114
+ Every option is a keyword of `Lingara::Client.new`:
115
+
116
+ | Keyword | Default |
117
+ |---|---|
118
+ | `client_id:`, `client_secret:` | none: a credential-free client |
119
+ | `auth:` | `:basic` (`client_secret_basic`); `:post` for `client_secret_post` |
120
+ | `scopes:` | none: every scope the client is allowed |
121
+ | `token_source:` | a client-credentials source; any object with `#token` (returning a `Lingara::AccessToken`) and `#invalidate(token)` replaces it |
122
+ | `base_url:`, `token_url:` | `https://api.getlingara.com`, and its `/oauth/token` |
123
+ | `version:` | no `Lingara-Version` header |
124
+ | `on_deprecation:` | one warning per version id through `logger:`; a callable receiving a `Lingara::DeprecationNotice` replaces it |
125
+ | `logger:` | `Kernel#warn` (so `ruby -W0` silences it); any object with `warn` and `debug`, such as `Rails.logger` |
126
+ | `max_attempts:`, `retry_after_cap:` | `3`, `60` |
127
+ | `stream_idle_timeout:`, `token_request_timeout:` | `120`, `30` (seconds) |
128
+ | `user_agent_suffix:` | none; appended after the library's own product token |
129
+ | `net_http_options:` | `{}`: forwarded to `Net::HTTP.start` (`ca_file:`, `open_timeout:` …). The library always sets `max_retries` to `0`, and a stream's `read_timeout` is the idle timeout |
130
+ | `clock:`, `sleeper:` | `Time.now` and `sleep`. **Testing seams only**: the clock decides token freshness and HTTP-date waits, and the sleeper receives every `Retry-After` wait |
131
+
132
+ The secret and every access token render as `[REDACTED]` in `inspect`,
133
+ `to_s` and `pp`; `#expose_secret` is the one accessor that reads them. A
134
+ client is safe to share between threads: concurrent callers share one token
135
+ exchange, which runs on its own thread, and a process forked mid-exchange
136
+ makes its own.
137
+
138
+ ## The contract
139
+
140
+ This library keeps the contract every official Lingara library keeps:
141
+ [`conformance/CONTRACT.md`](../conformance/CONTRACT.md). Each connection is
142
+ opened for one request; the library pools none.
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lingara
4
+ # Renders a secret-holding object as [REDACTED] everywhere Ruby renders
5
+ # one: #inspect, #to_s and pp (K1, redaction).
6
+ module Redacted
7
+ REDACTED = "[REDACTED]"
8
+
9
+ def pretty_print(q)
10
+ q.text(inspect)
11
+ end
12
+ end
13
+
14
+ # An opaque access token, as a token source returns it. It renders as
15
+ # [REDACTED]; #expose_secret is the one way to read it. A caller's own
16
+ # token source builds one with AccessToken.new(raw).
17
+ class AccessToken
18
+ include Redacted
19
+
20
+ def initialize(raw)
21
+ raise ArgumentError, "an access token is a non-empty String" unless raw.is_a?(String) && !raw.empty?
22
+ @raw = raw.dup.freeze
23
+ end
24
+
25
+ # The raw token: the one accessor that does not redact.
26
+ def expose_secret
27
+ @raw
28
+ end
29
+
30
+ def ==(other)
31
+ other.is_a?(AccessToken) && other.expose_secret == @raw
32
+ end
33
+ alias_method :eql?, :==
34
+
35
+ def hash
36
+ @raw.hash
37
+ end
38
+
39
+ def to_s
40
+ REDACTED
41
+ end
42
+
43
+ def inspect
44
+ "#<Lingara::AccessToken #{REDACTED}>"
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Lingara
6
+ # Calls the Lingara API. Every C2 knob is a keyword of Client.new (D3); a
7
+ # client is safe to share between threads.
8
+ #
9
+ # client = Lingara::Client.new(client_id: ENV.fetch("LINGARA_CLIENT_ID"),
10
+ # client_secret: ENV.fetch("LINGARA_CLIENT_SECRET"))
11
+ # client.get_usage.value
12
+ #
13
+ # clock: and sleeper: are testing seams (C2 D9): the clock is read for
14
+ # token freshness and HTTP-date Retry-After values, and the sleeper is
15
+ # handed every Retry-After wait in seconds.
16
+ class Client
17
+ include Redacted
18
+
19
+ DEFAULT_BASE_URL = "https://api.getlingara.com"
20
+ DEFAULT_TOKEN_URL = "https://api.getlingara.com/oauth/token"
21
+ CREDENTIAL_KEYWORDS = %i[client_id client_secret auth scopes].freeze
22
+
23
+ attr_reader :token_source
24
+
25
+ def initialize(client_id: nil, client_secret: nil, auth: nil, scopes: nil, token_source: nil,
26
+ base_url: DEFAULT_BASE_URL, token_url: DEFAULT_TOKEN_URL, version: nil, on_deprecation: nil,
27
+ logger: DefaultLogger.new, max_attempts: 3, retry_after_cap: 60, stream_idle_timeout: 120,
28
+ token_request_timeout: 30, user_agent_suffix: nil, clock: -> { Time.now },
29
+ sleeper: ->(seconds) { sleep(seconds) }, net_http_options: {})
30
+ credentials = {client_id: client_id, client_secret: client_secret, auth: auth, scopes: scopes}
31
+ check_options(credentials, token_source, version, stream_idle_timeout)
32
+ @base_url = base_url.to_s.chomp("/")
33
+ @version = version
34
+ @idle = stream_idle_timeout
35
+ @user_agent = UserAgent.build(user_agent_suffix)
36
+ @policy = RetryPolicy.new(max_attempts: max_attempts, retry_after_cap: retry_after_cap, clock: clock, sleeper: sleeper)
37
+ @transport = Transport.new(net_http_options: net_http_options)
38
+ @versions = VersionObserver.new(hook: on_deprecation, logger: logger)
39
+ @token_source = token_source || (client_id && ClientCredentials.new(
40
+ client_id: client_id, client_secret: client_secret, auth: auth || :basic, scopes: scopes, token_url: token_url,
41
+ transport: @transport, policy: @policy, user_agent: @user_agent, token_request_timeout: token_request_timeout
42
+ ))
43
+ end
44
+
45
+ # ── The nine operations, over operations.rb ───────────────────────────
46
+
47
+ # Streams a vocabulary list (scope vocab:generate).
48
+ def generate_vocabulary(**body, &block)
49
+ stream("generateVocabulary", [], body, &block)
50
+ end
51
+
52
+ # Streams a new lesson plan's generation (scope lesson_plans:write). A
53
+ # plan served from the library is a lone result.
54
+ def create_lesson_plan(**body, &block)
55
+ stream("createLessonPlan", [], body, &block)
56
+ end
57
+
58
+ # Fetches a lesson plan by its id (scope lesson_plans:read).
59
+ def get_lesson_plan(plan_id)
60
+ json("getLessonPlan", [plan_id])
61
+ end
62
+
63
+ # Rejoins a lesson plan's generation by its id (scope lesson_plans:read).
64
+ def stream_lesson_plan(plan_id, &block)
65
+ stream("streamLessonPlan", [plan_id], nil, &block)
66
+ end
67
+
68
+ # Streams the tutor's reply to one turn (scope tutor:converse).
69
+ def send_tutor_message(**body, &block)
70
+ stream("sendTutorMessage", [], body, &block)
71
+ end
72
+
73
+ # This client's allowance, or its ledger if it is metered (scope
74
+ # usage:read).
75
+ def get_usage
76
+ json("getUsage", [])
77
+ end
78
+
79
+ # The API's OpenAPI document, as a Hash. Needs no token.
80
+ def get_open_api_document
81
+ json("getOpenApiDocument", [])
82
+ end
83
+
84
+ # The API's versions. Needs no token.
85
+ def list_api_versions
86
+ json("listApiVersions", [])
87
+ end
88
+
89
+ # One API version, by its id. Needs no token.
90
+ def get_api_version(version_id)
91
+ json("getApiVersion", [version_id])
92
+ end
93
+
94
+ def to_s
95
+ inspect
96
+ end
97
+
98
+ def inspect
99
+ "#<Lingara::Client base_url=#{@base_url.inspect} version=#{@version.inspect} token_source=#{@token_source.inspect}>"
100
+ end
101
+
102
+ private
103
+
104
+ def check_options(credentials, token_source, version, idle)
105
+ if token_source && credentials.any? { |_, value| !value.nil? }
106
+ raise ArgumentError, "token_source: cannot be combined with #{CREDENTIAL_KEYWORDS.map { |k| "#{k}:" }.join(", ")}"
107
+ end
108
+ if credentials[:client_id].nil? != credentials[:client_secret].nil?
109
+ raise ArgumentError, "client_id: and client_secret: are given together or not at all"
110
+ end
111
+ raise ArgumentError, "version: must not be empty" if version == ""
112
+ raise ArgumentError, "stream_idle_timeout: must be a positive number" unless idle.is_a?(Numeric) && idle.positive?
113
+ end
114
+
115
+ def json(id, args)
116
+ operation = OPERATIONS.fetch(id)
117
+ url = url_for(operation, args)
118
+ pipeline(operation, url, nil) do |response, _phase, observe|
119
+ served = observe.call(response)
120
+ Response.new(value: Decoding.response(operation[:response], response.body.to_s), served_version: served)
121
+ end
122
+ end
123
+
124
+ def stream(id, args, body, &block)
125
+ operation = OPERATIONS.fetch(id)
126
+ url = url_for(operation, args)
127
+ # The request model's constructor refuses a bad keyword before any
128
+ # request is sent: a programming error, like Client.new's.
129
+ payload = body && JSON.generate(Lingara.const_get(operation[:request_body]).new(body).to_hash)
130
+ events = EventStream.new(pipeline: method(:pipeline), operation: operation, url: url, body: payload)
131
+ block ? events.run(&block) : events
132
+ end
133
+
134
+ # Sends one call: its token and the one 401 retry, the Retry-After loop,
135
+ # and the refusal mapping. Yields a 2xx response, its Transport::Phase and
136
+ # the version observer, and returns what the block returns. A client with
137
+ # no token source sends an operation that needs one without
138
+ # Authorization; the server's 401 is the answer.
139
+ def pipeline(operation, url, body, on_start: nil, &on_success)
140
+ send_all = lambda do |token|
141
+ @policy.run { attempt(operation, url, body, token, on_start, &on_success) }
142
+ end
143
+ result = if @token_source && operation[:needs_token]
144
+ RetryPolicy.with_token_retry(@token_source, &send_all)
145
+ else
146
+ send_all.call(nil)
147
+ end
148
+ return result.value if result.done
149
+ raise Refusal.error(:v1, result.response, result.body, @policy.now)
150
+ end
151
+
152
+ def attempt(operation, url, body, token, on_start)
153
+ observe = ->(response) { @versions.observe(response, url) }
154
+ result = @transport.request(operation[:method], url, headers(operation, body, token), body: body,
155
+ read_timeout: operation[:stream] && @idle, secrets: token ? [token.expose_secret] : [], on_start: on_start) do |response, phase|
156
+ next Attempt.new(response: response, body: response.body.to_s) unless (200..299).cover?(response.code.to_i)
157
+ Attempt.new(value: yield(response, phase, observe), done: true)
158
+ end
159
+ # Phase#leave hands back the stream's value directly.
160
+ result.is_a?(Attempt) ? result : Attempt.new(value: result, done: true)
161
+ end
162
+
163
+ def headers(operation, body, token)
164
+ headers = {"Accept" => operation[:stream] ? "text/event-stream" : "application/json", "User-Agent" => @user_agent}
165
+ headers["Content-Type"] = "application/json" if body
166
+ headers["Authorization"] = "Bearer #{token.expose_secret}" if token
167
+ headers["Lingara-Version"] = @version if @version
168
+ headers
169
+ end
170
+
171
+ # The base URL and the route's path, each path parameter percent-encoded
172
+ # but for A–Z a–z 0–9 - . _ ~, as the other libraries do.
173
+ def url_for(operation, args)
174
+ path = operation[:path].dup
175
+ operation[:path_params].zip(args).each do |name, value|
176
+ raise ArgumentError, "#{name} must be a non-empty String" unless value.is_a?(String) && !value.empty?
177
+ path.sub!("{#{name}}", value.b.gsub(/[^A-Za-z0-9\-._~]/n) { |c| format("%%%02X", c.ord) })
178
+ end
179
+ @base_url + path
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "timeout"
5
+ require "uri"
6
+
7
+ module Lingara
8
+ # K1: the OAuth 2.0 client-credentials token source (CONTRACT.md K1; D5).
9
+ #
10
+ # It caches one token and replaces it min(60 s, expires_in / 2) before it
11
+ # expires, shares one exchange between concurrent callers, and clears only
12
+ # the token a 401 was answered with. The exchange runs on a thread of its
13
+ # own, and every caller, the one that started it included, waits on a
14
+ # ConditionVariable: a caller's interrupt ends only that caller's wait,
15
+ # while the flight completes and caches its token. Detached from every
16
+ # caller, each HTTP attempt of it carries token_request_timeout:.
17
+ #
18
+ # Any object with #token (returning an AccessToken) and #invalidate(token)
19
+ # is a token source; this is the one a client builds from client_id: and
20
+ # client_secret:.
21
+ class ClientCredentials
22
+ include Redacted
23
+
24
+ # The one exchange every concurrent caller waits on. +pid+ is the process
25
+ # that started it: a thread does not survive fork.
26
+ Flight = Struct.new(:pid, :done, :token, :error)
27
+ Cached = Struct.new(:token, :stale_at)
28
+
29
+ attr_reader :client_id
30
+
31
+ def initialize(client_id:, client_secret:, token_url:, transport:, policy:, user_agent:, auth: :basic, scopes: nil,
32
+ token_request_timeout: 30)
33
+ raise ArgumentError, "auth: must be :basic or :post" unless %i[basic post].include?(auth)
34
+ raise ArgumentError, "token_request_timeout: must be a positive number" unless token_request_timeout.is_a?(Numeric) && token_request_timeout.positive?
35
+ @client_id = client_id
36
+ @secret = client_secret
37
+ @config = {token_url: token_url, transport: transport, user_agent: user_agent, auth: auth, scopes: Array(scopes), timeout: token_request_timeout}
38
+ @policy = policy
39
+ @mutex = Mutex.new
40
+ @cv = ConditionVariable.new
41
+ @cached = nil
42
+ @flight = nil
43
+ end
44
+
45
+ # The raw client secret: the one accessor that does not redact.
46
+ def expose_secret
47
+ @secret
48
+ end
49
+
50
+ # The cached token while it is fresh; otherwise the result of the one
51
+ # flight, started if none is running.
52
+ def token
53
+ @mutex.synchronize do
54
+ # A flight another process started has no thread here (D5, fork).
55
+ @flight = nil if @flight && @flight.pid != Process.pid
56
+ return @cached.token if @cached && @policy.now < @cached.stale_at
57
+ flight = @flight ||= start_flight
58
+ @cv.wait(@mutex) until flight.done
59
+ raise flight.error if flight.error
60
+ flight.token
61
+ end
62
+ end
63
+
64
+ # Forgets +token+ only if it is still the cached one. During an exchange
65
+ # it is a no-op: nothing is cached yet.
66
+ def invalidate(token)
67
+ @mutex.synchronize do
68
+ @cached = nil if @cached && @cached.token == token
69
+ end
70
+ end
71
+
72
+ def to_s
73
+ inspect
74
+ end
75
+
76
+ def inspect
77
+ "#<Lingara::ClientCredentials client_id=#{@client_id.inspect} client_secret=#{REDACTED}>"
78
+ end
79
+
80
+ private
81
+
82
+ # Called with @mutex held.
83
+ def start_flight
84
+ flight = Flight.new(Process.pid, false, nil, nil)
85
+ thread = Thread.new { fly(flight) }
86
+ thread.name = "lingara-token"
87
+ thread.report_on_exception = false
88
+ flight
89
+ end
90
+
91
+ def fly(flight)
92
+ cached = exchange
93
+ rescue => e
94
+ error = e
95
+ ensure
96
+ @mutex.synchronize do
97
+ @flight = nil if @flight.equal?(flight)
98
+ if cached
99
+ @cached = cached
100
+ flight.token = cached.token
101
+ else
102
+ flight.error = error || TransportError.new(:connect, "the token exchange ended without an answer")
103
+ end
104
+ flight.done = true
105
+ @cv.broadcast
106
+ end
107
+ end
108
+
109
+ def exchange
110
+ sent_at = nil
111
+ attempt = @policy.run do
112
+ # obtained_at is when the request that succeeded was sent.
113
+ sent_at = @policy.now
114
+ post
115
+ end
116
+ unless (200..299).cover?(attempt.status)
117
+ raise Refusal.error(:token, attempt.response, attempt.body, @policy.now)
118
+ end
119
+ token, lifetime = grant(attempt.body)
120
+ skew = [60, lifetime / 2.0].min
121
+ Cached.new(token, sent_at + lifetime - skew)
122
+ end
123
+
124
+ # One exchange attempt and its whole body, under the token request
125
+ # timeout. Retry-After sleeps happen between attempts, so they never
126
+ # count against it.
127
+ def post
128
+ Timeout.timeout(@config[:timeout], TokenAttemptTimeout) do
129
+ @config[:transport].request("POST", @config[:token_url], headers, body: form, secrets: [@secret]) do |response, _phase|
130
+ Attempt.new(response: response, body: response.body.to_s)
131
+ end
132
+ end
133
+ rescue TokenAttemptTimeout
134
+ raise TransportError.new(:timeout, "the token request timed out")
135
+ end
136
+
137
+ def headers
138
+ headers = {
139
+ "Content-Type" => "application/x-www-form-urlencoded",
140
+ "Accept" => "application/json",
141
+ "User-Agent" => @config[:user_agent]
142
+ }
143
+ headers["Authorization"] = basic_auth if @config[:auth] == :basic
144
+ headers
145
+ end
146
+
147
+ # The grant, any scopes, and the credentials only under
148
+ # client_secret_post: never both places.
149
+ def form
150
+ pairs = [["grant_type", "client_credentials"]]
151
+ pairs << ["scope", @config[:scopes].join(" ")] unless @config[:scopes].empty?
152
+ pairs.push(["client_id", @client_id], ["client_secret", @secret]) if @config[:auth] == :post
153
+ URI.encode_www_form(pairs)
154
+ end
155
+
156
+ # Basic base64(form(id) ":" form(secret)), each half form-encoded per
157
+ # RFC 6749 §2.3.1. [s].pack("m0") is base64 with no bundled gem.
158
+ def basic_auth
159
+ pair = "#{URI.encode_www_form_component(@client_id)}:#{URI.encode_www_form_component(@secret)}"
160
+ "Basic #{[pair].pack("m0")}"
161
+ end
162
+
163
+ # A 200's token and lifetime: malformed unless it has an access_token, an
164
+ # expires_in and a Bearer token_type.
165
+ def grant(body)
166
+ fields = JSON.parse(body)
167
+ raw, lifetime, type = fields.values_at("access_token", "expires_in", "token_type") if fields.is_a?(Hash)
168
+ unless raw.is_a?(String) && !raw.empty? && lifetime.is_a?(Numeric) && lifetime >= 0 && type.is_a?(String) && type.casecmp?("bearer")
169
+ raise TransportError.new(:malformed_response, "the token response lacks access_token, expires_in or a Bearer token_type")
170
+ end
171
+ [AccessToken.new(raw), lifetime]
172
+ rescue JSON::ParserError
173
+ raise TransportError.new(:malformed_response, "the token response is not JSON")
174
+ end
175
+ end
176
+ end