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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +142 -0
- data/lib/lingara/access_token.rb +47 -0
- data/lib/lingara/client.rb +182 -0
- data/lib/lingara/client_credentials.rb +176 -0
- data/lib/lingara/deprecation.rb +128 -0
- data/lib/lingara/errors.rb +162 -0
- data/lib/lingara/event_stream.rb +135 -0
- data/lib/lingara/models/allowance_row.rb +378 -0
- data/lib/lingara/models/create_lesson_plan_event_error.rb +287 -0
- data/lib/lingara/models/create_lesson_plan_event_phase.rb +287 -0
- data/lib/lingara/models/create_lesson_plan_event_result.rb +287 -0
- data/lib/lingara/models/create_lesson_plan_event_started.rb +287 -0
- data/lib/lingara/models/error_envelope.rb +264 -0
- data/lib/lingara/models/generate_vocabulary_event_done.rb +287 -0
- data/lib/lingara/models/generate_vocabulary_event_error.rb +287 -0
- data/lib/lingara/models/generate_vocabulary_event_item.rb +287 -0
- data/lib/lingara/models/generate_vocabulary_event_started.rb +287 -0
- data/lib/lingara/models/lesson_plan.rb +463 -0
- data/lib/lingara/models/lesson_plan_content.rb +305 -0
- data/lib/lingara/models/lesson_plan_create_request.rb +333 -0
- data/lib/lingara/models/notice.rb +263 -0
- data/lib/lingara/models/plan_pending.rb +285 -0
- data/lib/lingara/models/plan_phase.rb +281 -0
- data/lib/lingara/models/plan_question.rb +337 -0
- data/lib/lingara/models/plan_result.rb +237 -0
- data/lib/lingara/models/plan_set.rb +293 -0
- data/lib/lingara/models/plan_started.rb +237 -0
- data/lib/lingara/models/plan_status.rb +42 -0
- data/lib/lingara/models/plan_word.rb +273 -0
- data/lib/lingara/models/send_tutor_message_event_delta.rb +287 -0
- data/lib/lingara/models/send_tutor_message_event_done.rb +287 -0
- data/lib/lingara/models/send_tutor_message_event_error.rb +287 -0
- data/lib/lingara/models/send_tutor_message_event_notice.rb +287 -0
- data/lib/lingara/models/stream_error.rb +273 -0
- data/lib/lingara/models/stream_lesson_plan_event_error.rb +287 -0
- data/lib/lingara/models/stream_lesson_plan_event_pending.rb +287 -0
- data/lib/lingara/models/stream_lesson_plan_event_phase.rb +287 -0
- data/lib/lingara/models/stream_lesson_plan_event_result.rb +287 -0
- data/lib/lingara/models/stream_lesson_plan_event_started.rb +287 -0
- data/lib/lingara/models/turn_delta.rb +237 -0
- data/lib/lingara/models/turn_entry.rb +285 -0
- data/lib/lingara/models/turn_role.rb +41 -0
- data/lib/lingara/models/tutor_turn_request.rb +354 -0
- data/lib/lingara/models/usage.rb +250 -0
- data/lib/lingara/models/usage_ledger.rb +307 -0
- data/lib/lingara/models/version_detail.rb +415 -0
- data/lib/lingara/models/version_history_entry.rb +285 -0
- data/lib/lingara/models/version_list.rb +263 -0
- data/lib/lingara/models/version_spec.rb +249 -0
- data/lib/lingara/models/version_state.rb +44 -0
- data/lib/lingara/models/version_summary.rb +349 -0
- data/lib/lingara/models/vocab_example.rb +263 -0
- data/lib/lingara/models/vocab_item.rb +303 -0
- data/lib/lingara/models/vocab_meta.rb +394 -0
- data/lib/lingara/models/vocab_request.rb +341 -0
- data/lib/lingara/models/vocab_started.rb +237 -0
- data/lib/lingara/operations.rb +91 -0
- data/lib/lingara/response.rb +46 -0
- data/lib/lingara/retry.rb +88 -0
- data/lib/lingara/sse_decoder.rb +80 -0
- data/lib/lingara/streams.rb +104 -0
- data/lib/lingara/transport.rb +131 -0
- data/lib/lingara/user_agent.rb +24 -0
- data/lib/lingara/version.rb +13 -0
- data/lib/lingara.rb +23 -0
- data/sig/lingara.rbs +151 -0
- 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
|