lingara 0.1.0.pre.alpha.6 → 0.1.0.pre.alpha.10
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/README.md +73 -7
- data/lib/lingara/client.rb +106 -18
- data/lib/lingara/errors.rb +9 -0
- data/lib/lingara/event_stream.rb +1 -14
- data/lib/lingara/events/catalogue.rb +107 -0
- data/lib/lingara/events/feed.rb +47 -0
- data/lib/lingara/events/tail.rb +112 -0
- data/lib/lingara/events/webhook.rb +148 -0
- data/lib/lingara/events.rb +15 -0
- data/lib/lingara/models/allowance_row.rb +1 -1
- data/lib/lingara/models/app_context_slice.rb +43 -0
- data/lib/lingara/models/app_installed_data.rb +317 -0
- data/lib/lingara/models/app_uninstalled_data.rb +263 -0
- data/lib/lingara/models/create_lesson_plan_event_error.rb +1 -1
- data/lib/lingara/models/create_lesson_plan_event_phase.rb +1 -1
- data/lib/lingara/models/create_lesson_plan_event_result.rb +1 -1
- data/lib/lingara/models/create_lesson_plan_event_started.rb +1 -1
- data/lib/lingara/models/error_envelope.rb +1 -1
- data/lib/lingara/models/event_envelope.rb +369 -0
- data/lib/lingara/models/event_page.rb +291 -0
- data/lib/lingara/models/generate_vocabulary_event_done.rb +1 -1
- data/lib/lingara/models/generate_vocabulary_event_error.rb +1 -1
- data/lib/lingara/models/generate_vocabulary_event_item.rb +1 -1
- data/lib/lingara/models/generate_vocabulary_event_started.rb +1 -1
- data/lib/lingara/models/inbound_event_accepted.rb +299 -0
- data/lib/lingara/models/lesson_plan.rb +1 -1
- data/lib/lingara/models/lesson_plan_content.rb +1 -1
- data/lib/lingara/models/lesson_plan_create_request.rb +1 -1
- data/lib/lingara/models/lesson_plan_failed_data.rb +285 -0
- data/lib/lingara/models/lesson_plan_ready_data.rb +393 -0
- data/lib/lingara/models/notice.rb +1 -1
- data/lib/lingara/models/npc.rb +289 -0
- data/lib/lingara/models/plan_fail_reason.rb +41 -0
- data/lib/lingara/models/plan_pending.rb +1 -1
- data/lib/lingara/models/plan_phase.rb +1 -1
- data/lib/lingara/models/plan_question.rb +1 -1
- data/lib/lingara/models/plan_ready_status.rb +41 -0
- data/lib/lingara/models/plan_result.rb +1 -1
- data/lib/lingara/models/plan_set.rb +1 -1
- data/lib/lingara/models/plan_started.rb +1 -1
- data/lib/lingara/models/plan_status.rb +1 -1
- data/lib/lingara/models/plan_word.rb +1 -1
- data/lib/lingara/models/reaction_report.rb +300 -0
- data/lib/lingara/models/reaction_status.rb +42 -0
- data/lib/lingara/models/send_tutor_message_event_delta.rb +1 -1
- data/lib/lingara/models/send_tutor_message_event_done.rb +1 -1
- data/lib/lingara/models/send_tutor_message_event_error.rb +1 -1
- data/lib/lingara/models/send_tutor_message_event_notice.rb +1 -1
- data/lib/lingara/models/stream_error.rb +1 -1
- data/lib/lingara/models/stream_events_event_done.rb +287 -0
- data/lib/lingara/models/stream_events_event_error.rb +287 -0
- data/lib/lingara/models/stream_events_event_event.rb +287 -0
- data/lib/lingara/models/stream_lesson_plan_event_error.rb +1 -1
- data/lib/lingara/models/stream_lesson_plan_event_pending.rb +1 -1
- data/lib/lingara/models/stream_lesson_plan_event_phase.rb +1 -1
- data/lib/lingara/models/stream_lesson_plan_event_result.rb +1 -1
- data/lib/lingara/models/stream_lesson_plan_event_started.rb +1 -1
- data/lib/lingara/models/threshold_scope.rb +41 -0
- data/lib/lingara/models/turn_delta.rb +1 -1
- data/lib/lingara/models/turn_entry.rb +1 -1
- data/lib/lingara/models/turn_role.rb +1 -1
- data/lib/lingara/models/tutor_turn_request.rb +1 -1
- data/lib/lingara/models/usage.rb +1 -1
- data/lib/lingara/models/usage_ledger.rb +1 -1
- data/lib/lingara/models/usage_threshold_reached_data.rb +323 -0
- data/lib/lingara/models/version_detail.rb +31 -5
- data/lib/lingara/models/version_history_entry.rb +1 -1
- data/lib/lingara/models/version_list.rb +1 -1
- data/lib/lingara/models/version_spec.rb +1 -1
- data/lib/lingara/models/version_state.rb +1 -1
- data/lib/lingara/models/version_summary.rb +1 -1
- data/lib/lingara/models/vocab_example.rb +1 -1
- data/lib/lingara/models/vocab_item.rb +1 -1
- data/lib/lingara/models/vocab_meta.rb +1 -1
- data/lib/lingara/models/vocab_request.rb +1 -1
- data/lib/lingara/models/vocab_started.rb +1 -1
- data/lib/lingara/models/world_context_changed.rb +401 -0
- data/lib/lingara/models/world_practice_requested.rb +391 -0
- data/lib/lingara/operations.rb +36 -0
- data/lib/lingara/response.rb +12 -0
- data/lib/lingara/sse_decoder.rb +11 -4
- data/lib/lingara/streams.rb +24 -0
- data/lib/lingara/version.rb +3 -3
- data/lib/lingara.rb +1 -0
- data/sig/lingara.rbs +113 -1
- metadata +27 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2f07665061b17c5e8388d90fd529d4bda81a9b18b14f6deb8d5c529564a77343
|
|
4
|
+
data.tar.gz: 1588f9415feaf547ae947c9590c9c87e79afd5ff32400fac34868232e5b54fae
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 934088a50dbcb89e78dcfc49ee42de87888266b4f84dbcde35eaf7f90c06068cf8dc005db6c1e1174ec86b6263f56c5684f7a58b6cb18e786a53d2ed998f2cdd
|
|
7
|
+
data.tar.gz: bb59e7307ae5f904409649db90970e66edd3546bea4cc9bcf43c16affdae0d3ff172f4a65ba4741c505f946969067b9164f6fe644470742449a9be3b786fc3da
|
data/README.md
CHANGED
|
@@ -40,10 +40,12 @@ usage = client.get_usage # Lingara::Response
|
|
|
40
40
|
p usage.value.allowance, usage.served_version
|
|
41
41
|
```
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
Thirteen methods, each the `operationId` in snake_case: `generate_vocabulary`,
|
|
44
44
|
`create_lesson_plan`, `get_lesson_plan`, `stream_lesson_plan`,
|
|
45
45
|
`send_tutor_message`, `get_usage`, `get_open_api_document`,
|
|
46
|
-
`list_api_versions
|
|
46
|
+
`get_async_api_document`, `list_api_versions`, `get_api_version`,
|
|
47
|
+
`list_events`, `stream_events` and `send_event`, plus the two event helpers
|
|
48
|
+
`events` and `tail_events` (see *Webhooks and events*). A path parameter is a positional
|
|
47
49
|
`String` (`client.get_lesson_plan(plan_id)`); a request body is keyword
|
|
48
50
|
arguments, and an unknown or missing keyword raises `ArgumentError` before any
|
|
49
51
|
request is sent.
|
|
@@ -52,10 +54,10 @@ A JSON method returns a `Lingara::Response`: `#value` is the generated model
|
|
|
52
54
|
(a `Hash` for `get_open_api_document`) and `#served_version` is the
|
|
53
55
|
`Lingara-Version` the server answered under, or `nil`.
|
|
54
56
|
|
|
55
|
-
`get_open_api_document`, `list_api_versions` and
|
|
56
|
-
token, so `Lingara::Client.new` with no credentials
|
|
57
|
-
without credentials sends the other
|
|
58
|
-
server's `401` is the answer.
|
|
57
|
+
`get_open_api_document`, `get_async_api_document`, `list_api_versions` and
|
|
58
|
+
`get_api_version` need no token, so `Lingara::Client.new` with no credentials
|
|
59
|
+
calls them. A client without credentials sends the other nine without
|
|
60
|
+
`Authorization`, and the server's `401` is the answer.
|
|
59
61
|
|
|
60
62
|
## Streams
|
|
61
63
|
|
|
@@ -92,6 +94,69 @@ A stream fails with `Lingara::TransportError` (`kind: :timeout`) after
|
|
|
92
94
|
while the library is waiting for your next event. Time you spend holding an
|
|
93
95
|
event never counts.
|
|
94
96
|
|
|
97
|
+
## Webhooks and events
|
|
98
|
+
|
|
99
|
+
Every door carries one envelope (`id`, `type`, `created_at`, `api_version`,
|
|
100
|
+
`subject`, `data`), and the library reads it into a `Lingara::Events::Event`:
|
|
101
|
+
one `Data` class per type (`Lingara::Events::LessonPlanReady`, whose `data`
|
|
102
|
+
is a `Lingara::LessonPlanReadyData`, and so on), or
|
|
103
|
+
`Lingara::Events::UnknownEvent`, whose `data` is the raw `Hash`.
|
|
104
|
+
`Lingara::Events.parse(json)` is the parser all three doors use.
|
|
105
|
+
|
|
106
|
+
**Verify the raw body first.** `Lingara::Events::Webhook.new(secret)` (or an
|
|
107
|
+
array of two secrets during a rotation) refuses anything but a
|
|
108
|
+
`lgr_whsec_…` secret with `ArgumentError`. `#verify(body, headers)` takes the
|
|
109
|
+
body exactly as received, a `String`, never a parsed object, and any `Hash`
|
|
110
|
+
of headers, read case-insensitively and by Rack's `HTTP_WEBHOOK_ID`
|
|
111
|
+
spelling, so a Rack `env` works as it is:
|
|
112
|
+
|
|
113
|
+
```ruby
|
|
114
|
+
webhook = Lingara::Events::Webhook.new(ENV.fetch("LINGARA_WEBHOOK_SECRET"))
|
|
115
|
+
event = webhook.verify(request.body.read, request.env) # Rack, Sinatra and Rails alike
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
It returns the event, or raises `Lingara::Events::VerificationError`, whose
|
|
119
|
+
`#reason` is `:missing_header`, `:malformed_header`, `:timestamp_too_old`,
|
|
120
|
+
`:timestamp_too_new` (300 s either way), `:no_matching_signature` or
|
|
121
|
+
`:malformed_payload`. That error is deliberately **not** a `Lingara::Error`,
|
|
122
|
+
so a `rescue Lingara::Error` around your API calls never swallows a forged
|
|
123
|
+
webhook. `#verify_signature(body, headers)` checks the signature alone, for a
|
|
124
|
+
signed body that is not an event.
|
|
125
|
+
|
|
126
|
+
**Answer `2xx` fast and deduplicate by `event.id`**: delivery is at least once,
|
|
127
|
+
and the verifier stores nothing. **Acknowledge an `UnknownEvent`** (and log
|
|
128
|
+
it) rather than refusing it: the catalogue only grows, and a refused delivery
|
|
129
|
+
is retried for about a day.
|
|
130
|
+
|
|
131
|
+
**The feed.** `client.events(cursor:, start:, types:)` walks
|
|
132
|
+
`list_events`' pages and yields each event, then stops at the horizon: it
|
|
133
|
+
never waits. Its `#cursor` is where to resume, so keep it and pass it back
|
|
134
|
+
later. Without a cursor, `start: :latest` (the default) means "from now" and
|
|
135
|
+
`:oldest` means everything still kept. A cursor older than 30 days raises
|
|
136
|
+
`Lingara::ApiError` with `code` `"cursor_expired"`: start again with no
|
|
137
|
+
cursor, or with `start: :oldest`.
|
|
138
|
+
|
|
139
|
+
**The tail.** `client.tail_events(cursor:, start:, types:)` yields live
|
|
140
|
+
events and reconnects on its own from its `#cursor`, which is the same token
|
|
141
|
+
as the feed's, so `tail_events(cursor: feed.cursor)` follows a catch-up with
|
|
142
|
+
no gap. Leave the block to stop it. After `tail_max_failures:` (8)
|
|
143
|
+
consecutive failed reconnects, 91 s of backoff by default, it raises the last
|
|
144
|
+
failure; it never hides an outage forever. `stream_events` is the raw
|
|
145
|
+
one-connection stream beneath it.
|
|
146
|
+
|
|
147
|
+
**Sending.** `client.send_event(event, idempotency_key: nil)` takes a
|
|
148
|
+
`Lingara::Events::InboundEvent`, such as
|
|
149
|
+
`Lingara::Events::InboundEvent.world_context_changed(scene: "…", source_lang: "en", target_lang: "zh", level: 2, generate: true)`,
|
|
150
|
+
and returns the `InboundEventAccepted`. Without a key the library makes one
|
|
151
|
+
per call and repeats it on every retry. Supply your own when a game may
|
|
152
|
+
resend after a crash, since a generated key dies with the call; a key reused
|
|
153
|
+
for another event returns the **first** answer. Only
|
|
154
|
+
`reaction.plan_status == "generating"` promises a `lesson_plan.ready` or
|
|
155
|
+
`lesson_plan.failed`.
|
|
156
|
+
|
|
157
|
+
Event `data` is rendered at your client's pinned version, and this library's
|
|
158
|
+
models are those of `Lingara::GENERATED_FOR_VERSION`: pin your client to it.
|
|
159
|
+
|
|
95
160
|
## Errors
|
|
96
161
|
|
|
97
162
|
Every failure is a `Lingara::Error`, one of four:
|
|
@@ -127,7 +192,8 @@ Every option is a keyword of `Lingara::Client.new`:
|
|
|
127
192
|
| `stream_idle_timeout:`, `token_request_timeout:` | `120`, `30` (seconds) |
|
|
128
193
|
| `user_agent_suffix:` | none; appended after the library's own product token |
|
|
129
194
|
| `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
|
-
| `
|
|
195
|
+
| `tail_max_failures:` | `8`: consecutive failed reconnects before `tail_events` raises |
|
|
196
|
+
| `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 and every tail backoff |
|
|
131
197
|
|
|
132
198
|
The secret and every access token render as `[REDACTED]` in `inspect`,
|
|
133
199
|
`to_s` and `pp`; `#expose_secret` is the one accessor that reads them. A
|
data/lib/lingara/client.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require "securerandom"
|
|
4
5
|
|
|
5
6
|
module Lingara
|
|
6
7
|
# Calls the Lingara API. Every C2 knob is a keyword of Client.new (D3); a
|
|
@@ -12,7 +13,9 @@ module Lingara
|
|
|
12
13
|
#
|
|
13
14
|
# clock: and sleeper: are testing seams (C2 D9): the clock is read for
|
|
14
15
|
# token freshness and HTTP-date Retry-After values, and the sleeper is
|
|
15
|
-
# handed every Retry-After wait in seconds.
|
|
16
|
+
# handed every Retry-After wait in seconds, and every tail backoff.
|
|
17
|
+
# tail_max_failures: bounds tail_events' consecutive failed reopens
|
|
18
|
+
# (K5a; ADR 30.9.26aa D7).
|
|
16
19
|
class Client
|
|
17
20
|
include Redacted
|
|
18
21
|
|
|
@@ -26,14 +29,18 @@ module Lingara
|
|
|
26
29
|
base_url: DEFAULT_BASE_URL, token_url: DEFAULT_TOKEN_URL, version: nil, on_deprecation: nil,
|
|
27
30
|
logger: DefaultLogger.new, max_attempts: 3, retry_after_cap: 60, stream_idle_timeout: 120,
|
|
28
31
|
token_request_timeout: 30, user_agent_suffix: nil, clock: -> { Time.now },
|
|
29
|
-
sleeper: ->(seconds) { sleep(seconds) }, net_http_options: {})
|
|
32
|
+
sleeper: ->(seconds) { sleep(seconds) }, net_http_options: {}, tail_max_failures: 8)
|
|
30
33
|
credentials = {client_id: client_id, client_secret: client_secret, auth: auth, scopes: scopes}
|
|
31
34
|
check_options(credentials, token_source, version, stream_idle_timeout)
|
|
35
|
+
raise ArgumentError, "tail_max_failures: must be at least 1" unless tail_max_failures.is_a?(Integer) && tail_max_failures >= 1
|
|
36
|
+
@tail_max_failures = tail_max_failures
|
|
32
37
|
@base_url = base_url.to_s.chomp("/")
|
|
33
38
|
@version = version
|
|
34
39
|
@idle = stream_idle_timeout
|
|
35
40
|
@user_agent = UserAgent.build(user_agent_suffix)
|
|
36
41
|
@policy = RetryPolicy.new(max_attempts: max_attempts, retry_after_cap: retry_after_cap, clock: clock, sleeper: sleeper)
|
|
42
|
+
# A tail open bypasses K4's attempt loop: its own count is the budget.
|
|
43
|
+
@tail_policy = RetryPolicy.new(max_attempts: 1, retry_after_cap: retry_after_cap, clock: clock, sleeper: sleeper)
|
|
37
44
|
@transport = Transport.new(net_http_options: net_http_options)
|
|
38
45
|
@versions = VersionObserver.new(hook: on_deprecation, logger: logger)
|
|
39
46
|
@token_source = token_source || (client_id && ClientCredentials.new(
|
|
@@ -42,7 +49,7 @@ module Lingara
|
|
|
42
49
|
))
|
|
43
50
|
end
|
|
44
51
|
|
|
45
|
-
# ── The
|
|
52
|
+
# ── The thirteen operations, over operations.rb ───────────────────────
|
|
46
53
|
|
|
47
54
|
# Streams a vocabulary list (scope vocab:generate).
|
|
48
55
|
def generate_vocabulary(**body, &block)
|
|
@@ -91,6 +98,66 @@ module Lingara
|
|
|
91
98
|
json("getApiVersion", [version_id])
|
|
92
99
|
end
|
|
93
100
|
|
|
101
|
+
# The API's AsyncAPI document, the event catalogue, as a Hash. Needs no
|
|
102
|
+
# token.
|
|
103
|
+
def get_async_api_document
|
|
104
|
+
json("getAsyncApiDocument", [])
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# ── Events (ADR 30.9.26aa D6–D8) ──────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
# One page of events, an EventPage (scope events:read). +types+ is a
|
|
110
|
+
# list, sent comma-separated.
|
|
111
|
+
def list_events(cursor: nil, start: nil, types: nil, limit: nil)
|
|
112
|
+
json("listEvents", [], query: {cursor: cursor, start: start, types: types, limit: limit})
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# The feed: an Events::Feed that walks pages from +cursor+ (or +start+,
|
|
116
|
+
# :latest or :oldest, without one) to the horizon and yields each
|
|
117
|
+
# Events::Event. With a block, iterates it and returns the feed, whose
|
|
118
|
+
# #cursor is where to resume.
|
|
119
|
+
def events(cursor: nil, start: nil, types: nil, &block)
|
|
120
|
+
operation = OPERATIONS.fetch("listEvents")
|
|
121
|
+
fetch = ->(query) { page(operation, url_for(operation, [], query)) }
|
|
122
|
+
feed = Events::Feed.new(fetch: fetch, cursor: cursor, start: start, types: types)
|
|
123
|
+
block ? feed.each(&block) : feed
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# The event stream itself, one connection under K5 (scope events:read):
|
|
127
|
+
# it yields StreamEventsEventEvent and ends on done. tail_events is the
|
|
128
|
+
# helper that resumes.
|
|
129
|
+
def stream_events(cursor: nil, start: nil, types: nil, last_event_id: nil, &block)
|
|
130
|
+
headers = last_event_id ? {"Last-Event-ID" => last_event_id} : {}
|
|
131
|
+
stream("streamEvents", [], nil, query: {cursor: cursor, start: start, types: types}, headers: headers, &block)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# The tail (K5a): an Events::Tail that yields each Events::Event and
|
|
135
|
+
# reconnects from its #cursor after any ending, until
|
|
136
|
+
# tail_max_failures: consecutive failures. It never ends on its own:
|
|
137
|
+
# leave the block to stop it.
|
|
138
|
+
def tail_events(cursor: nil, start: nil, types: nil, &block)
|
|
139
|
+
operation = OPERATIONS.fetch("streamEvents")
|
|
140
|
+
# The first request's URL, repeated by every reopen: start only
|
|
141
|
+
# without a cursor, which travels as Last-Event-ID instead.
|
|
142
|
+
url = url_for(operation, [], {start: (start unless cursor), types: types})
|
|
143
|
+
open = lambda do |last_event_id, on_start, &consume|
|
|
144
|
+
headers = last_event_id ? {"Last-Event-ID" => last_event_id} : {}
|
|
145
|
+
pipeline(operation, url, nil, on_start: on_start, headers: headers, policy: @tail_policy, &consume)
|
|
146
|
+
end
|
|
147
|
+
tail = Events::Tail.new(open: open, cursor: cursor, max_failures: @tail_max_failures, policy: @policy)
|
|
148
|
+
block ? tail.each(&block) : tail
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Sends one Events::InboundEvent (scope events:write, and
|
|
152
|
+
# lesson_plans:write when it asks for generation) and returns the
|
|
153
|
+
# InboundEventAccepted. Every attempt carries one Idempotency-Key: the
|
|
154
|
+
# caller's, unchanged, or a UUIDv4 made once for this call (K4).
|
|
155
|
+
def send_event(event, idempotency_key: nil)
|
|
156
|
+
raise ArgumentError, "event must be a Lingara::Events::InboundEvent" unless event.is_a?(Events::InboundEvent)
|
|
157
|
+
key = idempotency_key || SecureRandom.uuid
|
|
158
|
+
json("sendEvent", [], body: JSON.generate(event.to_hash), headers: {"Idempotency-Key" => key})
|
|
159
|
+
end
|
|
160
|
+
|
|
94
161
|
def to_s
|
|
95
162
|
inspect
|
|
96
163
|
end
|
|
@@ -112,22 +179,33 @@ module Lingara
|
|
|
112
179
|
raise ArgumentError, "stream_idle_timeout: must be a positive number" unless idle.is_a?(Numeric) && idle.positive?
|
|
113
180
|
end
|
|
114
181
|
|
|
115
|
-
|
|
182
|
+
# +body+ is JSON text already; +headers+ join every attempt's.
|
|
183
|
+
def json(id, args, query: nil, body: nil, headers: {})
|
|
116
184
|
operation = OPERATIONS.fetch(id)
|
|
117
|
-
url = url_for(operation, args)
|
|
118
|
-
pipeline(operation, url,
|
|
185
|
+
url = url_for(operation, args, query)
|
|
186
|
+
pipeline(operation, url, body, headers: headers) do |response, _phase, observe|
|
|
119
187
|
served = observe.call(response)
|
|
120
188
|
Response.new(value: Decoding.response(operation[:response], response.body.to_s), served_version: served)
|
|
121
189
|
end
|
|
122
190
|
end
|
|
123
191
|
|
|
124
|
-
|
|
192
|
+
# One page of the feed as its raw Hash, so each item reaches
|
|
193
|
+
# Events.decode as the bytes the server sent (D6).
|
|
194
|
+
def page(operation, url)
|
|
195
|
+
pipeline(operation, url, nil) do |response, _phase, observe|
|
|
196
|
+
observe.call(response)
|
|
197
|
+
Decoding.page(response.body.to_s)
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def stream(id, args, body, query: nil, headers: {}, &block)
|
|
125
202
|
operation = OPERATIONS.fetch(id)
|
|
126
|
-
url = url_for(operation, args)
|
|
203
|
+
url = url_for(operation, args, query)
|
|
127
204
|
# The request model's constructor refuses a bad keyword before any
|
|
128
205
|
# request is sent: a programming error, like Client.new's.
|
|
129
206
|
payload = body && JSON.generate(Lingara.const_get(operation[:request_body]).new(body).to_hash)
|
|
130
|
-
|
|
207
|
+
send = ->(*call, **options, &consume) { pipeline(*call, headers: headers, **options, &consume) }
|
|
208
|
+
events = EventStream.new(pipeline: send, operation: operation, url: url, body: payload)
|
|
131
209
|
block ? events.run(&block) : events
|
|
132
210
|
end
|
|
133
211
|
|
|
@@ -135,10 +213,11 @@ module Lingara
|
|
|
135
213
|
# and the refusal mapping. Yields a 2xx response, its Transport::Phase and
|
|
136
214
|
# the version observer, and returns what the block returns. A client with
|
|
137
215
|
# no token source sends an operation that needs one without
|
|
138
|
-
# Authorization; the server's 401 is the answer.
|
|
139
|
-
|
|
216
|
+
# Authorization; the server's 401 is the answer. +policy+ is the tail's
|
|
217
|
+
# single-attempt one for a tail open (K5a).
|
|
218
|
+
def pipeline(operation, url, body, on_start: nil, headers: {}, policy: @policy, &on_success)
|
|
140
219
|
send_all = lambda do |token|
|
|
141
|
-
|
|
220
|
+
policy.run { attempt(operation, url, body, [token, headers], on_start, &on_success) }
|
|
142
221
|
end
|
|
143
222
|
result = if @token_source && operation[:needs_token]
|
|
144
223
|
RetryPolicy.with_token_retry(@token_source, &send_all)
|
|
@@ -149,9 +228,11 @@ module Lingara
|
|
|
149
228
|
raise Refusal.error(:v1, result.response, result.body, @policy.now)
|
|
150
229
|
end
|
|
151
230
|
|
|
152
|
-
|
|
231
|
+
# +auth+ is the token and the call's own headers.
|
|
232
|
+
def attempt(operation, url, body, auth, on_start)
|
|
233
|
+
token, extra = auth
|
|
153
234
|
observe = ->(response) { @versions.observe(response, url) }
|
|
154
|
-
result = @transport.request(operation[:method], url, headers(operation, body, token), body: body,
|
|
235
|
+
result = @transport.request(operation[:method], url, headers(operation, body, token).merge(extra), body: body,
|
|
155
236
|
read_timeout: operation[:stream] && @idle, secrets: token ? [token.expose_secret] : [], on_start: on_start) do |response, phase|
|
|
156
237
|
next Attempt.new(response: response, body: response.body.to_s) unless (200..299).cover?(response.code.to_i)
|
|
157
238
|
Attempt.new(value: yield(response, phase, observe), done: true)
|
|
@@ -169,14 +250,21 @@ module Lingara
|
|
|
169
250
|
end
|
|
170
251
|
|
|
171
252
|
# 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
|
-
|
|
253
|
+
# but for A–Z a–z 0–9 - . _ ~, as the other libraries do. +query+'s nil
|
|
254
|
+
# values are left out and a list is one comma-separated value
|
|
255
|
+
# (`explode: false`), encoded the same way.
|
|
256
|
+
def url_for(operation, args, query = nil)
|
|
174
257
|
path = operation[:path].dup
|
|
175
258
|
operation[:path_params].zip(args).each do |name, value|
|
|
176
259
|
raise ArgumentError, "#{name} must be a non-empty String" unless value.is_a?(String) && !value.empty?
|
|
177
|
-
path.sub!("{#{name}}", value
|
|
260
|
+
path.sub!("{#{name}}", encode(value))
|
|
178
261
|
end
|
|
179
|
-
|
|
262
|
+
pairs = (query || {}).reject { |_, value| value.nil? }.map { |name, value| "#{name}=#{encode(Array(value).join(","))}" }
|
|
263
|
+
pairs.empty? ? @base_url + path : "#{@base_url}#{path}?#{pairs.join("&")}"
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def encode(value)
|
|
267
|
+
value.to_s.b.gsub(/[^A-Za-z0-9\-._~]/n) { |c| format("%%%02X", c.ord) }
|
|
180
268
|
end
|
|
181
269
|
end
|
|
182
270
|
end
|
data/lib/lingara/errors.rb
CHANGED
|
@@ -135,6 +135,15 @@ module Lingara
|
|
|
135
135
|
ApiError.new(status: status, code: code, message: message, retry_after: retry_after, served_version: served_version)
|
|
136
136
|
end
|
|
137
137
|
|
|
138
|
+
# A stream's error event: ApiError with status 200. K5 raises it at
|
|
139
|
+
# once; the tail (K5a) only once its failures are spent.
|
|
140
|
+
def stream_error(data, served_version)
|
|
141
|
+
fields = parse_object(data)
|
|
142
|
+
text = ->(key, fallback) { fields[key].is_a?(String) ? fields[key] : fallback }
|
|
143
|
+
ApiError.new(status: 200, code: text.call("code", "stream_error"), message: text.call("message", "the stream reported an error"),
|
|
144
|
+
plan_id: text.call("plan_id", nil), served_version: served_version)
|
|
145
|
+
end
|
|
146
|
+
|
|
138
147
|
def parse_object(body)
|
|
139
148
|
value = JSON.parse(body.to_s)
|
|
140
149
|
value.is_a?(Hash) ? value : {}
|
data/lib/lingara/event_stream.rb
CHANGED
|
@@ -103,7 +103,7 @@ module Lingara
|
|
|
103
103
|
stream = @operation[:stream]
|
|
104
104
|
return unless stream[:events].key?(frame.event)
|
|
105
105
|
ending = stream[:ends][frame.event]
|
|
106
|
-
raise stream_error(frame.data) if ending == :raise
|
|
106
|
+
raise Refusal.stream_error(frame.data, @served_version) if ending == :raise
|
|
107
107
|
return finish(phase, frame) if ending == :quiet
|
|
108
108
|
event = Lingara.const_get(stream[:union]).decode(frame.event, frame.data)
|
|
109
109
|
return if event.nil?
|
|
@@ -118,18 +118,5 @@ module Lingara
|
|
|
118
118
|
rescue JSON::ParserError
|
|
119
119
|
raise TransportError.new(:malformed_event, "#{frame.event}: data is not JSON")
|
|
120
120
|
end
|
|
121
|
-
|
|
122
|
-
# An error event: ApiError with status 200, never yielded or retried.
|
|
123
|
-
def stream_error(data)
|
|
124
|
-
fields = begin
|
|
125
|
-
parsed = JSON.parse(data)
|
|
126
|
-
parsed.is_a?(Hash) ? parsed : {}
|
|
127
|
-
rescue JSON::ParserError
|
|
128
|
-
{}
|
|
129
|
-
end
|
|
130
|
-
text = ->(key, fallback) { fields[key].is_a?(String) ? fields[key] : fallback }
|
|
131
|
-
ApiError.new(status: 200, code: text.call("code", "stream_error"), message: text.call("message", "the stream reported an error"),
|
|
132
|
-
plan_id: text.call("plan_id", nil), served_version: @served_version)
|
|
133
|
-
end
|
|
134
121
|
end
|
|
135
122
|
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# Generated by ruby/codegen/generate.rb from the generator view. DO NOT EDIT.
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Lingara
|
|
6
|
+
module Events
|
|
7
|
+
# The envelope's fields, in the order every arm holds them.
|
|
8
|
+
ENVELOPE = %i[id type created_at api_version subject data].freeze
|
|
9
|
+
|
|
10
|
+
# One outbound event. Every arm, UnknownEvent included, is an Event, so
|
|
11
|
+
# `case event in Lingara::Events::Event` matches any of them.
|
|
12
|
+
module Event
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# lesson_plan.ready: data is a Lingara::LessonPlanReadyData.
|
|
16
|
+
LessonPlanReady = Data.define(*ENVELOPE) { include Event }
|
|
17
|
+
|
|
18
|
+
# lesson_plan.failed: data is a Lingara::LessonPlanFailedData.
|
|
19
|
+
LessonPlanFailed = Data.define(*ENVELOPE) { include Event }
|
|
20
|
+
|
|
21
|
+
# usage.threshold_reached: data is a Lingara::UsageThresholdReachedData.
|
|
22
|
+
UsageThresholdReached = Data.define(*ENVELOPE) { include Event }
|
|
23
|
+
|
|
24
|
+
# webhook.test: data is a Hash.
|
|
25
|
+
WebhookTest = Data.define(*ENVELOPE) { include Event }
|
|
26
|
+
|
|
27
|
+
# app.installed: data is a Lingara::AppInstalledData.
|
|
28
|
+
AppInstalled = Data.define(*ENVELOPE) { include Event }
|
|
29
|
+
|
|
30
|
+
# app.uninstalled: data is a Lingara::AppUninstalledData.
|
|
31
|
+
AppUninstalled = Data.define(*ENVELOPE) { include Event }
|
|
32
|
+
|
|
33
|
+
# An event of a type this library does not know: data is the raw JSON
|
|
34
|
+
# object, as a Hash. The catalogue only grows, so acknowledge it.
|
|
35
|
+
UnknownEvent = Data.define(*ENVELOPE) { include Event }
|
|
36
|
+
|
|
37
|
+
module Event
|
|
38
|
+
# Each known type's arm and the name of its data model (nil: a Hash).
|
|
39
|
+
ARMS = {
|
|
40
|
+
"lesson_plan.ready" => [LessonPlanReady, "LessonPlanReadyData"].freeze,
|
|
41
|
+
"lesson_plan.failed" => [LessonPlanFailed, "LessonPlanFailedData"].freeze,
|
|
42
|
+
"usage.threshold_reached" => [UsageThresholdReached, "UsageThresholdReachedData"].freeze,
|
|
43
|
+
"webhook.test" => [WebhookTest, nil].freeze,
|
|
44
|
+
"app.installed" => [AppInstalled, "AppInstalledData"].freeze,
|
|
45
|
+
"app.uninstalled" => [AppUninstalled, "AppUninstalledData"].freeze
|
|
46
|
+
}.freeze
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# An event a client sends with send_event: one constructor per inbound
|
|
50
|
+
# type, each taking its data model. It serialises as {type, data}; the
|
|
51
|
+
# server assigns the rest.
|
|
52
|
+
class InboundEvent
|
|
53
|
+
# Each inbound type's constructor.
|
|
54
|
+
CONSTRUCTORS = {
|
|
55
|
+
"world.context_changed" => :world_context_changed,
|
|
56
|
+
"world.practice_requested" => :world_practice_requested
|
|
57
|
+
}.freeze
|
|
58
|
+
|
|
59
|
+
attr_reader :type, :data
|
|
60
|
+
|
|
61
|
+
private_class_method :new
|
|
62
|
+
|
|
63
|
+
# world.context_changed, from a Lingara::WorldContextChanged or the Hash one is built from.
|
|
64
|
+
def self.world_context_changed(data)
|
|
65
|
+
new("world.context_changed", data.is_a?(Lingara::WorldContextChanged) ? data : Lingara::WorldContextChanged.new(data))
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# world.practice_requested, from a Lingara::WorldPracticeRequested or the Hash one is built from.
|
|
69
|
+
def self.world_practice_requested(data)
|
|
70
|
+
new("world.practice_requested", data.is_a?(Lingara::WorldPracticeRequested) ? data : Lingara::WorldPracticeRequested.new(data))
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def initialize(type, data)
|
|
74
|
+
@type = type
|
|
75
|
+
@data = data
|
|
76
|
+
freeze
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def to_hash
|
|
80
|
+
{"type" => @type, "data" => @data.to_hash}
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# The Event for one envelope's JSON text: its arm for a known type, or
|
|
85
|
+
# UnknownEvent. Text that is not an envelope, or a known type whose data
|
|
86
|
+
# its model refuses, raises TransportError :malformed_event.
|
|
87
|
+
def self.parse(json)
|
|
88
|
+
decode(JSON.parse(json))
|
|
89
|
+
rescue JSON::ParserError
|
|
90
|
+
raise Lingara::TransportError.new(:malformed_event, "an event is not JSON")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# The same, from an envelope already parsed into a Hash.
|
|
94
|
+
def self.decode(fields)
|
|
95
|
+
raise Lingara::TransportError.new(:malformed_event, "an event is not a JSON object") unless fields.is_a?(Hash)
|
|
96
|
+
envelope = Lingara::EventEnvelope.build_from_hash(fields)
|
|
97
|
+
arm, model = Event::ARMS.fetch(envelope.type, [UnknownEvent, nil])
|
|
98
|
+
data = model ? Lingara.const_get(model).build_from_hash(envelope.data) : envelope.data
|
|
99
|
+
arm.new(id: envelope.id, type: envelope.type, created_at: envelope.created_at, api_version: envelope.api_version,
|
|
100
|
+
subject: envelope.subject, data: data)
|
|
101
|
+
rescue Lingara::Error
|
|
102
|
+
raise
|
|
103
|
+
rescue
|
|
104
|
+
raise Lingara::TransportError.new(:malformed_event, "an event does not decode")
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lingara
|
|
4
|
+
module Events
|
|
5
|
+
# The feed helper (ADR 30.9.26aa D6; CONTRACT.md, The event helpers): it
|
|
6
|
+
# walks listEvents' pages and yields each item as an Event, parsed from
|
|
7
|
+
# the item's own JSON. #cursor is where to resume: after a page's last
|
|
8
|
+
# item, that page's next_cursor, which advances even on an empty page.
|
|
9
|
+
# It ends on a page with has_more false and never sleeps or polls; a
|
|
10
|
+
# later #each resumes from #cursor. A known type whose data does not
|
|
11
|
+
# decode raises TransportError :malformed_event, and every refusal its
|
|
12
|
+
# K3 error, 410 cursor_expired included.
|
|
13
|
+
class Feed
|
|
14
|
+
include Enumerable
|
|
15
|
+
|
|
16
|
+
attr_reader :cursor
|
|
17
|
+
|
|
18
|
+
# +fetch+ is the client's: it sends one listEvents request for a query
|
|
19
|
+
# and returns the page as a Hash.
|
|
20
|
+
def initialize(fetch:, cursor:, start:, types:)
|
|
21
|
+
@fetch = fetch
|
|
22
|
+
@cursor = cursor
|
|
23
|
+
@start = start
|
|
24
|
+
@types = types
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Yields each event to the horizon, then returns self. Without a
|
|
28
|
+
# block, an Enumerator.
|
|
29
|
+
def each
|
|
30
|
+
return enum_for(:each) unless block_given?
|
|
31
|
+
loop do
|
|
32
|
+
# start only without a cursor, the server's own precedence, so the
|
|
33
|
+
# helper never earns the 400 the pair draws.
|
|
34
|
+
page = @fetch.call(@cursor ? {cursor: @cursor, types: @types} : {start: @start, types: @types})
|
|
35
|
+
page["items"].each { |item| yield Events.decode(item) }
|
|
36
|
+
@cursor = page["next_cursor"]
|
|
37
|
+
break unless page["has_more"]
|
|
38
|
+
end
|
|
39
|
+
self
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def inspect
|
|
43
|
+
"#<Lingara::Events::Feed cursor=#{@cursor.inspect}>"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lingara
|
|
4
|
+
module Events
|
|
5
|
+
# The tail helper (CONTRACT.md K5a; ADR 30.9.26aa D7): streamEvents,
|
|
6
|
+
# reopened after every ending, so it never ends on its own. Leave the
|
|
7
|
+
# block (break, return or an exception) to stop it, at any point,
|
|
8
|
+
# including a reconnect sleep.
|
|
9
|
+
#
|
|
10
|
+
# #cursor is the id of the last frame that carried one, an event or a
|
|
11
|
+
# done, and every reopen sends it as Last-Event-ID. A done reopens at
|
|
12
|
+
# once. An error event, EOF and every TransportError are failures, the
|
|
13
|
+
# first open included: the sleeper is handed 1, 2, 4 … 30 s between
|
|
14
|
+
# them, and the max_failures-th consecutive one is raised. The count
|
|
15
|
+
# resets on a connection's first event or done frame, never on its 200.
|
|
16
|
+
# A tail open bypasses K4: a 429 or 503 is one failure whose Retry-After
|
|
17
|
+
# (within retry_after_cap) replaces that step's delay; 401 gets K1's one
|
|
18
|
+
# refresh; every other refusal, and a known type whose data does not
|
|
19
|
+
# decode, is raised at once.
|
|
20
|
+
class Tail
|
|
21
|
+
include Enumerable
|
|
22
|
+
|
|
23
|
+
# Why one connection ended, and the delay that replaces the backoff's.
|
|
24
|
+
Failure = Struct.new(:error, :delay)
|
|
25
|
+
|
|
26
|
+
MAX_DELAY = 30
|
|
27
|
+
|
|
28
|
+
attr_reader :cursor
|
|
29
|
+
|
|
30
|
+
# +open+ is the client's: it sends one streamEvents request, with
|
|
31
|
+
# Last-Event-ID when given one, and yields the 2xx response.
|
|
32
|
+
def initialize(open:, cursor:, max_failures:, policy:)
|
|
33
|
+
@open = open
|
|
34
|
+
@cursor = cursor
|
|
35
|
+
@max_failures = max_failures
|
|
36
|
+
@policy = policy
|
|
37
|
+
@failures = 0
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Yields each event until the block is left or the failures are spent.
|
|
41
|
+
# Without a block, an Enumerator.
|
|
42
|
+
def each(&block)
|
|
43
|
+
return enum_for(:each) unless block
|
|
44
|
+
@failures = 0
|
|
45
|
+
loop do
|
|
46
|
+
failure = connect(&block)
|
|
47
|
+
next if failure.nil?
|
|
48
|
+
@failures += 1
|
|
49
|
+
raise failure.error if @failures >= @max_failures
|
|
50
|
+
@policy.sleeper.call(failure.delay || [2**(@failures - 1), MAX_DELAY].min)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def inspect
|
|
55
|
+
"#<Lingara::Events::Tail cursor=#{@cursor.inspect}>"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
# One connection: nil after a done, else its Failure.
|
|
61
|
+
def connect(&block)
|
|
62
|
+
@open.call(@cursor, nil) { |response, phase, observe| consume(response, phase, observe, &block) }
|
|
63
|
+
rescue TransportError => e
|
|
64
|
+
raise if e.kind == :malformed_event
|
|
65
|
+
Failure.new(e, nil)
|
|
66
|
+
rescue ApiError, MaintenanceError => e
|
|
67
|
+
busy(e)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# A 429 or 503 is a failure; any other refusal, and a Retry-After past
|
|
71
|
+
# the cap, is raised.
|
|
72
|
+
def busy(error)
|
|
73
|
+
status = error.is_a?(MaintenanceError) ? 503 : error.status
|
|
74
|
+
raise error unless [429, 503].include?(status)
|
|
75
|
+
raise error if error.retry_after && error.retry_after > @policy.retry_after_cap
|
|
76
|
+
Failure.new(error, error.retry_after)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def consume(response, phase, observe, &block)
|
|
80
|
+
unless Refusal.media_type(response) == "text/event-stream"
|
|
81
|
+
raise TransportError.new(:malformed_response, "a 200 stream answered #{Refusal.media_type(response).inspect}")
|
|
82
|
+
end
|
|
83
|
+
served = observe.call(response)
|
|
84
|
+
phase.event_stream = true
|
|
85
|
+
decoder = SSEDecoder.new
|
|
86
|
+
response.read_body do |chunk|
|
|
87
|
+
decoder.feed(chunk).each { |frame| handle(frame, phase, served, &block) }
|
|
88
|
+
end
|
|
89
|
+
Failure.new(TransportError.new(:stream_ended_early, "the tail closed before its ending event"), nil)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def handle(frame, phase, served, &block)
|
|
93
|
+
case frame.event
|
|
94
|
+
when "event"
|
|
95
|
+
event = Events.parse(frame.data)
|
|
96
|
+
advance(frame)
|
|
97
|
+
phase.in_caller_block { block.call(event) }
|
|
98
|
+
when "done"
|
|
99
|
+
advance(frame)
|
|
100
|
+
phase.leave(nil)
|
|
101
|
+
when "error"
|
|
102
|
+
phase.leave(Failure.new(Refusal.stream_error(frame.data, served), nil))
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def advance(frame)
|
|
107
|
+
@failures = 0
|
|
108
|
+
@cursor = frame.id if frame.id && !frame.id.empty?
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|