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.
Files changed (87) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +73 -7
  3. data/lib/lingara/client.rb +106 -18
  4. data/lib/lingara/errors.rb +9 -0
  5. data/lib/lingara/event_stream.rb +1 -14
  6. data/lib/lingara/events/catalogue.rb +107 -0
  7. data/lib/lingara/events/feed.rb +47 -0
  8. data/lib/lingara/events/tail.rb +112 -0
  9. data/lib/lingara/events/webhook.rb +148 -0
  10. data/lib/lingara/events.rb +15 -0
  11. data/lib/lingara/models/allowance_row.rb +1 -1
  12. data/lib/lingara/models/app_context_slice.rb +43 -0
  13. data/lib/lingara/models/app_installed_data.rb +317 -0
  14. data/lib/lingara/models/app_uninstalled_data.rb +263 -0
  15. data/lib/lingara/models/create_lesson_plan_event_error.rb +1 -1
  16. data/lib/lingara/models/create_lesson_plan_event_phase.rb +1 -1
  17. data/lib/lingara/models/create_lesson_plan_event_result.rb +1 -1
  18. data/lib/lingara/models/create_lesson_plan_event_started.rb +1 -1
  19. data/lib/lingara/models/error_envelope.rb +1 -1
  20. data/lib/lingara/models/event_envelope.rb +369 -0
  21. data/lib/lingara/models/event_page.rb +291 -0
  22. data/lib/lingara/models/generate_vocabulary_event_done.rb +1 -1
  23. data/lib/lingara/models/generate_vocabulary_event_error.rb +1 -1
  24. data/lib/lingara/models/generate_vocabulary_event_item.rb +1 -1
  25. data/lib/lingara/models/generate_vocabulary_event_started.rb +1 -1
  26. data/lib/lingara/models/inbound_event_accepted.rb +299 -0
  27. data/lib/lingara/models/lesson_plan.rb +1 -1
  28. data/lib/lingara/models/lesson_plan_content.rb +1 -1
  29. data/lib/lingara/models/lesson_plan_create_request.rb +1 -1
  30. data/lib/lingara/models/lesson_plan_failed_data.rb +285 -0
  31. data/lib/lingara/models/lesson_plan_ready_data.rb +393 -0
  32. data/lib/lingara/models/notice.rb +1 -1
  33. data/lib/lingara/models/npc.rb +289 -0
  34. data/lib/lingara/models/plan_fail_reason.rb +41 -0
  35. data/lib/lingara/models/plan_pending.rb +1 -1
  36. data/lib/lingara/models/plan_phase.rb +1 -1
  37. data/lib/lingara/models/plan_question.rb +1 -1
  38. data/lib/lingara/models/plan_ready_status.rb +41 -0
  39. data/lib/lingara/models/plan_result.rb +1 -1
  40. data/lib/lingara/models/plan_set.rb +1 -1
  41. data/lib/lingara/models/plan_started.rb +1 -1
  42. data/lib/lingara/models/plan_status.rb +1 -1
  43. data/lib/lingara/models/plan_word.rb +1 -1
  44. data/lib/lingara/models/reaction_report.rb +300 -0
  45. data/lib/lingara/models/reaction_status.rb +42 -0
  46. data/lib/lingara/models/send_tutor_message_event_delta.rb +1 -1
  47. data/lib/lingara/models/send_tutor_message_event_done.rb +1 -1
  48. data/lib/lingara/models/send_tutor_message_event_error.rb +1 -1
  49. data/lib/lingara/models/send_tutor_message_event_notice.rb +1 -1
  50. data/lib/lingara/models/stream_error.rb +1 -1
  51. data/lib/lingara/models/stream_events_event_done.rb +287 -0
  52. data/lib/lingara/models/stream_events_event_error.rb +287 -0
  53. data/lib/lingara/models/stream_events_event_event.rb +287 -0
  54. data/lib/lingara/models/stream_lesson_plan_event_error.rb +1 -1
  55. data/lib/lingara/models/stream_lesson_plan_event_pending.rb +1 -1
  56. data/lib/lingara/models/stream_lesson_plan_event_phase.rb +1 -1
  57. data/lib/lingara/models/stream_lesson_plan_event_result.rb +1 -1
  58. data/lib/lingara/models/stream_lesson_plan_event_started.rb +1 -1
  59. data/lib/lingara/models/threshold_scope.rb +41 -0
  60. data/lib/lingara/models/turn_delta.rb +1 -1
  61. data/lib/lingara/models/turn_entry.rb +1 -1
  62. data/lib/lingara/models/turn_role.rb +1 -1
  63. data/lib/lingara/models/tutor_turn_request.rb +1 -1
  64. data/lib/lingara/models/usage.rb +1 -1
  65. data/lib/lingara/models/usage_ledger.rb +1 -1
  66. data/lib/lingara/models/usage_threshold_reached_data.rb +323 -0
  67. data/lib/lingara/models/version_detail.rb +31 -5
  68. data/lib/lingara/models/version_history_entry.rb +1 -1
  69. data/lib/lingara/models/version_list.rb +1 -1
  70. data/lib/lingara/models/version_spec.rb +1 -1
  71. data/lib/lingara/models/version_state.rb +1 -1
  72. data/lib/lingara/models/version_summary.rb +1 -1
  73. data/lib/lingara/models/vocab_example.rb +1 -1
  74. data/lib/lingara/models/vocab_item.rb +1 -1
  75. data/lib/lingara/models/vocab_meta.rb +1 -1
  76. data/lib/lingara/models/vocab_request.rb +1 -1
  77. data/lib/lingara/models/vocab_started.rb +1 -1
  78. data/lib/lingara/models/world_context_changed.rb +401 -0
  79. data/lib/lingara/models/world_practice_requested.rb +391 -0
  80. data/lib/lingara/operations.rb +36 -0
  81. data/lib/lingara/response.rb +12 -0
  82. data/lib/lingara/sse_decoder.rb +11 -4
  83. data/lib/lingara/streams.rb +24 -0
  84. data/lib/lingara/version.rb +3 -3
  85. data/lib/lingara.rb +1 -0
  86. data/sig/lingara.rbs +113 -1
  87. metadata +27 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9900f36d73945227ac18de4de3520240ed8be150dc9db270802d55d5a858d60d
4
- data.tar.gz: 84897db9f327ec33dc6823a11b2489a29e5da845f86f0c7be8ed2b2b87fdbbc4
3
+ metadata.gz: 2f07665061b17c5e8388d90fd529d4bda81a9b18b14f6deb8d5c529564a77343
4
+ data.tar.gz: 1588f9415feaf547ae947c9590c9c87e79afd5ff32400fac34868232e5b54fae
5
5
  SHA512:
6
- metadata.gz: d831190d39cc8a6d840d913764c06ef40129999b2908237cfbb1f1c7529f93a894883d15925a55a0006d321153beecf78c03c075ee466bc4380ddfc93c984453
7
- data.tar.gz: 683926e460ce43175de4fb365168726f61a0d145e0fc6f80dd2f482ca9171b624a633accd4934c0c8dd4b2cc0459a8f4663b181cfd8612c4b337514974d06228
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
- Nine methods, each the `operationId` in snake_case: `generate_vocabulary`,
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` and `get_api_version`. A path parameter is a positional
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 `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.
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
- | `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 |
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
@@ -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 nine operations, over operations.rb ───────────────────────────
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
- def json(id, args)
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, nil) do |response, _phase, observe|
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
- def stream(id, args, body, &block)
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
- events = EventStream.new(pipeline: method(:pipeline), operation: operation, url: url, body: payload)
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
- def pipeline(operation, url, body, on_start: nil, &on_success)
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
- @policy.run { attempt(operation, url, body, token, on_start, &on_success) }
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
- def attempt(operation, url, body, token, on_start)
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
- def url_for(operation, args)
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.b.gsub(/[^A-Za-z0-9\-._~]/n) { |c| format("%%%02X", c.ord) })
260
+ path.sub!("{#{name}}", encode(value))
178
261
  end
179
- @base_url + path
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
@@ -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 : {}
@@ -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