lingara 0.1.0.pre.alpha.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +142 -0
  4. data/lib/lingara/access_token.rb +47 -0
  5. data/lib/lingara/client.rb +182 -0
  6. data/lib/lingara/client_credentials.rb +176 -0
  7. data/lib/lingara/deprecation.rb +128 -0
  8. data/lib/lingara/errors.rb +162 -0
  9. data/lib/lingara/event_stream.rb +135 -0
  10. data/lib/lingara/models/allowance_row.rb +378 -0
  11. data/lib/lingara/models/create_lesson_plan_event_error.rb +287 -0
  12. data/lib/lingara/models/create_lesson_plan_event_phase.rb +287 -0
  13. data/lib/lingara/models/create_lesson_plan_event_result.rb +287 -0
  14. data/lib/lingara/models/create_lesson_plan_event_started.rb +287 -0
  15. data/lib/lingara/models/error_envelope.rb +264 -0
  16. data/lib/lingara/models/generate_vocabulary_event_done.rb +287 -0
  17. data/lib/lingara/models/generate_vocabulary_event_error.rb +287 -0
  18. data/lib/lingara/models/generate_vocabulary_event_item.rb +287 -0
  19. data/lib/lingara/models/generate_vocabulary_event_started.rb +287 -0
  20. data/lib/lingara/models/lesson_plan.rb +463 -0
  21. data/lib/lingara/models/lesson_plan_content.rb +305 -0
  22. data/lib/lingara/models/lesson_plan_create_request.rb +333 -0
  23. data/lib/lingara/models/notice.rb +263 -0
  24. data/lib/lingara/models/plan_pending.rb +285 -0
  25. data/lib/lingara/models/plan_phase.rb +281 -0
  26. data/lib/lingara/models/plan_question.rb +337 -0
  27. data/lib/lingara/models/plan_result.rb +237 -0
  28. data/lib/lingara/models/plan_set.rb +293 -0
  29. data/lib/lingara/models/plan_started.rb +237 -0
  30. data/lib/lingara/models/plan_status.rb +42 -0
  31. data/lib/lingara/models/plan_word.rb +273 -0
  32. data/lib/lingara/models/send_tutor_message_event_delta.rb +287 -0
  33. data/lib/lingara/models/send_tutor_message_event_done.rb +287 -0
  34. data/lib/lingara/models/send_tutor_message_event_error.rb +287 -0
  35. data/lib/lingara/models/send_tutor_message_event_notice.rb +287 -0
  36. data/lib/lingara/models/stream_error.rb +273 -0
  37. data/lib/lingara/models/stream_lesson_plan_event_error.rb +287 -0
  38. data/lib/lingara/models/stream_lesson_plan_event_pending.rb +287 -0
  39. data/lib/lingara/models/stream_lesson_plan_event_phase.rb +287 -0
  40. data/lib/lingara/models/stream_lesson_plan_event_result.rb +287 -0
  41. data/lib/lingara/models/stream_lesson_plan_event_started.rb +287 -0
  42. data/lib/lingara/models/turn_delta.rb +237 -0
  43. data/lib/lingara/models/turn_entry.rb +285 -0
  44. data/lib/lingara/models/turn_role.rb +41 -0
  45. data/lib/lingara/models/tutor_turn_request.rb +354 -0
  46. data/lib/lingara/models/usage.rb +250 -0
  47. data/lib/lingara/models/usage_ledger.rb +307 -0
  48. data/lib/lingara/models/version_detail.rb +415 -0
  49. data/lib/lingara/models/version_history_entry.rb +285 -0
  50. data/lib/lingara/models/version_list.rb +263 -0
  51. data/lib/lingara/models/version_spec.rb +249 -0
  52. data/lib/lingara/models/version_state.rb +44 -0
  53. data/lib/lingara/models/version_summary.rb +349 -0
  54. data/lib/lingara/models/vocab_example.rb +263 -0
  55. data/lib/lingara/models/vocab_item.rb +303 -0
  56. data/lib/lingara/models/vocab_meta.rb +394 -0
  57. data/lib/lingara/models/vocab_request.rb +341 -0
  58. data/lib/lingara/models/vocab_started.rb +237 -0
  59. data/lib/lingara/operations.rb +91 -0
  60. data/lib/lingara/response.rb +46 -0
  61. data/lib/lingara/retry.rb +88 -0
  62. data/lib/lingara/sse_decoder.rb +80 -0
  63. data/lib/lingara/streams.rb +104 -0
  64. data/lib/lingara/transport.rb +131 -0
  65. data/lib/lingara/user_agent.rb +24 -0
  66. data/lib/lingara/version.rb +13 -0
  67. data/lib/lingara.rb +23 -0
  68. data/sig/lingara.rbs +151 -0
  69. metadata +114 -0
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require "uri"
5
+
6
+ module Lingara
7
+ # A Link header: its raw value, and its target resolved against the
8
+ # request URL (RFC 8288 §3.2), or nil when it has none.
9
+ DeprecationLink = Struct.new(:raw, :target)
10
+
11
+ # What a response under a deprecated version says about it (K2). An
12
+ # unparseable header leaves its parsed field nil, never an error.
13
+ # +deprecated_at+ and +sunset_at+ are Times; +headers+ are the raw
14
+ # Deprecation, Sunset and Link values.
15
+ DeprecationNotice = Struct.new(:version, :deprecated_at, :sunset_at, :link, :headers) do
16
+ # The notice for a response, or nil when it carries no Deprecation.
17
+ def self.from(response, request_url)
18
+ raw = response["Deprecation"]
19
+ return nil if raw.nil? || raw.strip.empty?
20
+ sunset = response["Sunset"]
21
+ link = response["Link"]
22
+ new(
23
+ version: response["Lingara-Version"],
24
+ deprecated_at: parse_deprecation(raw),
25
+ sunset_at: parse_sunset(sunset),
26
+ link: link && parse_link(link, request_url),
27
+ headers: {"Deprecation" => raw, "Sunset" => sunset, "Link" => link}.compact
28
+ )
29
+ end
30
+
31
+ def self.parse_deprecation(value)
32
+ digits = value.strip.delete_prefix("@")
33
+ return nil unless value.strip.start_with?("@") && digits.match?(/\A-?\d+\z/)
34
+ Time.at(digits.to_i).utc
35
+ end
36
+
37
+ # An IMF-fixdate only: Time.httpdate also reads the two obsolete formats,
38
+ # which Sunset does not allow.
39
+ def self.parse_sunset(value)
40
+ return nil if value.nil?
41
+ text = value.strip
42
+ return nil unless text.match?(/\A[A-Z][a-z]{2}, \d{2} [A-Z][a-z]{2} \d{4} \d{2}:\d{2}:\d{2} GMT\z/)
43
+ Time.httpdate(text)
44
+ rescue ArgumentError
45
+ nil
46
+ end
47
+
48
+ def self.parse_link(raw, request_url)
49
+ target = raw.strip[/\A<([^>]*)>/, 1]
50
+ resolved = target && URI.join(request_url, target)
51
+ DeprecationLink.new(raw: raw, target: resolved)
52
+ rescue URI::Error
53
+ DeprecationLink.new(raw: raw, target: nil)
54
+ end
55
+ end
56
+
57
+ # The default logger: #warn goes through Kernel#warn with no category, so
58
+ # it reaches Warning.warn and $stderr by default and `ruby -W0` silences
59
+ # it; #debug writes only under $DEBUG. Any object with #warn and #debug,
60
+ # such as Rails.logger, replaces it.
61
+ class DefaultLogger
62
+ def warn(message)
63
+ Kernel.warn(message)
64
+ end
65
+
66
+ def debug(message)
67
+ Kernel.warn(message) if $DEBUG
68
+ end
69
+ end
70
+
71
+ # Per client: reads the served version off each response and reports a
72
+ # deprecation once per response, to the hook or, with no hook, as one
73
+ # warning per version id. Separately, it warns once per served id that is
74
+ # not the version the models were generated from (ADR 30.9.26a §4).
75
+ class VersionObserver
76
+ def initialize(hook:, logger:)
77
+ @hook = hook
78
+ @logger = logger
79
+ @mutex = Mutex.new
80
+ @warned = {}
81
+ # Its own set: sharing @warned would let a version that is both
82
+ # deprecated and mismatched warn only once in total.
83
+ @mismatched = {}
84
+ end
85
+
86
+ # The Lingara-Version echo, or nil, after reporting any deprecation or
87
+ # mismatch.
88
+ def observe(response, request_url)
89
+ notice = DeprecationNotice.from(response, request_url)
90
+ report(notice) if notice
91
+ served = response["Lingara-Version"]
92
+ served = nil if served && served.empty?
93
+ check_generated(served) if served
94
+ served
95
+ end
96
+
97
+ private
98
+
99
+ def report(notice)
100
+ return warn_deprecated(notice) unless @hook
101
+ begin
102
+ @hook.call(notice)
103
+ rescue => e
104
+ @logger.debug("the Lingara deprecation hook raised #{e.class}; the call continues")
105
+ end
106
+ end
107
+
108
+ def warn_deprecated(notice)
109
+ # An absent echo counts as one id.
110
+ return unless first_time?(@warned, notice.version.to_s)
111
+ sunset = notice.headers["Sunset"] ? "; sunset #{notice.headers["Sunset"]}" : ""
112
+ @logger.warn("Lingara API version #{notice.version || "(unnamed)"} is deprecated#{sunset}. See GET /v1/versions.")
113
+ end
114
+
115
+ def check_generated(served)
116
+ return if served == GENERATED_FOR_VERSION || !first_time?(@mismatched, served)
117
+ @logger.warn("Lingara API version #{served} served this response, but this library's models were generated for " \
118
+ "#{GENERATED_FOR_VERSION}; response shapes may differ. Pin the OAuth client to #{GENERATED_FOR_VERSION} or upgrade the library.")
119
+ end
120
+
121
+ def first_time?(seen, id)
122
+ @mutex.synchronize do
123
+ next false if seen[id]
124
+ seen[id] = true
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Lingara
6
+ # K3: one error family (CONTRACT.md K3). Every failure a call raises, a
7
+ # caller's own interrupt aside, is one of the four subclasses below, so one
8
+ # `rescue Lingara::Error` handles them all.
9
+ class Error < StandardError
10
+ # The fields a subclass renders in #inspect, never a secret.
11
+ def inspect
12
+ fields = self.class::FIELDS.map { |name| "#{name}=#{public_send(name).inspect}" }
13
+ "#<#{self.class.name} #{fields.join(" ")}>"
14
+ end
15
+
16
+ alias_method :pretty_print_inspect, :inspect
17
+
18
+ def pretty_print(q)
19
+ q.text(inspect)
20
+ end
21
+ end
22
+
23
+ # A /v1 refusal, or a stream's error event (then +status+ is 200).
24
+ class ApiError < Error
25
+ FIELDS = %i[status code message retry_after plan_id served_version].freeze
26
+
27
+ attr_reader :status, :code, :retry_after, :plan_id, :served_version
28
+
29
+ def initialize(status:, code:, message:, retry_after: nil, plan_id: nil, served_version: nil)
30
+ @status = status
31
+ @code = code
32
+ @retry_after = retry_after
33
+ @plan_id = plan_id
34
+ @served_version = served_version
35
+ super("#{code}: #{message} (HTTP #{status})")
36
+ @detail = message
37
+ end
38
+
39
+ # The envelope's `error`, or the event's `message`. #to_s keeps the code
40
+ # and the status beside it.
41
+ def message
42
+ @detail || super
43
+ end
44
+ end
45
+
46
+ # A token-endpoint refusal: RFC 6749 §5.2, or `http_<status>`.
47
+ class OAuthError < Error
48
+ FIELDS = %i[status error description retry_after].freeze
49
+
50
+ attr_reader :status, :error, :description, :retry_after
51
+
52
+ def initialize(status:, error:, description: nil, retry_after: nil)
53
+ @status = status
54
+ @error = error
55
+ @description = description
56
+ @retry_after = retry_after
57
+ super(description ? "token endpoint: #{error}: #{description} (HTTP #{status})" : "token endpoint: #{error} (HTTP #{status})")
58
+ end
59
+ end
60
+
61
+ # A 503 whose body is not JSON: the service is in maintenance.
62
+ class MaintenanceError < Error
63
+ FIELDS = %i[body retry_after].freeze
64
+
65
+ attr_reader :body, :retry_after
66
+
67
+ def initialize(body:, retry_after: nil)
68
+ @body = body
69
+ @retry_after = retry_after
70
+ super("the API is under maintenance")
71
+ end
72
+ end
73
+
74
+ # A call with no usable HTTP answer. +kind+ is one of KINDS; +cause+ is the
75
+ # underlying failure, scrubbed when its text named a credential.
76
+ class TransportError < Error
77
+ FIELDS = %i[kind].freeze
78
+ KINDS = %i[connect tls reset timeout stream_ended_early malformed_response malformed_event].freeze
79
+
80
+ attr_reader :kind
81
+
82
+ def initialize(kind, detail = nil)
83
+ raise ArgumentError, "unknown transport kind #{kind.inspect}" unless KINDS.include?(kind)
84
+ @kind = kind
85
+ super(detail ? "transport failure: #{kind}: #{detail}" : "transport failure: #{kind}")
86
+ end
87
+ end
88
+
89
+ # Stands in for an underlying error whose text named a credential, as the
90
+ # +cause+ of the TransportError that replaces it.
91
+ class ScrubbedCause < StandardError
92
+ def initialize
93
+ super("the underlying error was withheld: it contained a credential")
94
+ end
95
+ end
96
+
97
+ # Response → error, in C2 D4's precedence.
98
+ module Refusal
99
+ MAINTENANCE_BODY_BYTES = 1024
100
+
101
+ module_function
102
+
103
+ # A non-2xx response to its K3 class. +endpoint+ is :v1 or :token; +now+
104
+ # reads an HTTP-date Retry-After.
105
+ def error(endpoint, response, body, now)
106
+ status = response.code.to_i
107
+ retry_after = RetryPolicy.parse_retry_after(response["Retry-After"], now)
108
+ if status == 503 && !json?(response)
109
+ return MaintenanceError.new(body: truncate(body.to_s, MAINTENANCE_BODY_BYTES), retry_after: retry_after)
110
+ end
111
+ fields = parse_object(body)
112
+ if endpoint == :token
113
+ return oauth_error(status, fields, retry_after)
114
+ end
115
+ api_error(status, fields, retry_after, response["Lingara-Version"])
116
+ end
117
+
118
+ def oauth_error(status, fields, retry_after)
119
+ code = fields["error"]
120
+ if code.is_a?(String)
121
+ description = fields["error_description"]
122
+ OAuthError.new(status: status, error: code, description: description.is_a?(String) ? description : nil, retry_after: retry_after)
123
+ else
124
+ OAuthError.new(status: status, error: "http_#{status}", retry_after: retry_after)
125
+ end
126
+ end
127
+
128
+ def api_error(status, fields, retry_after, served_version)
129
+ code = fields["code"]
130
+ message = fields["error"]
131
+ unless code.is_a?(String) && message.is_a?(String)
132
+ code = "http_#{status}"
133
+ message = "HTTP #{status}"
134
+ end
135
+ ApiError.new(status: status, code: code, message: message, retry_after: retry_after, served_version: served_version)
136
+ end
137
+
138
+ def parse_object(body)
139
+ value = JSON.parse(body.to_s)
140
+ value.is_a?(Hash) ? value : {}
141
+ rescue JSON::ParserError
142
+ {}
143
+ end
144
+
145
+ # The Content-Type's media type, parameters dropped and lower-cased.
146
+ def media_type(response)
147
+ response["Content-Type"].to_s.split(";").first.to_s.strip.downcase
148
+ end
149
+
150
+ def json?(response)
151
+ media = media_type(response)
152
+ media == "application/json" || media.end_with?("+json")
153
+ end
154
+
155
+ # At most +max+ bytes, cut on a character boundary.
156
+ def truncate(text, max)
157
+ text = text.dup.force_encoding(Encoding::UTF_8)
158
+ return text if text.bytesize <= max
159
+ text.byteslice(0, max).scrub("")
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Lingara
6
+ # K5: one stream of events (CONTRACT.md K5; D3).
7
+ #
8
+ # A stream call with a block sends at once, yields each event and returns a
9
+ # StreamResult. Without one it returns an EventStream, which sends nothing
10
+ # until its first #each or #next, and is single-use: a second #each raises
11
+ # IOError, as reading a closed IO does. Leaving the block by any path, or
12
+ # #close, closes the socket. The terminal table is the view's, generated
13
+ # into operations.rb (ADR 29.9.26ai): :raise, :quiet and :yield.
14
+ class EventStream
15
+ include Enumerable
16
+
17
+ # The Lingara-Version echo, or nil; nil until the first #each or #next.
18
+ attr_reader :served_version
19
+
20
+ # +pipeline+ is the client's: it sends the request, with its token, 401
21
+ # and Retry-After retries, and yields the 2xx response.
22
+ def initialize(pipeline:, operation:, url:, body:)
23
+ @pipeline = pipeline
24
+ @operation = operation
25
+ @url = url
26
+ @body = body
27
+ @mutex = Mutex.new
28
+ @consumed = false
29
+ @http = nil
30
+ @external = nil
31
+ @served_version = nil
32
+ end
33
+
34
+ # Yields each event, then returns self. Without a block, an Enumerator.
35
+ def each(&block)
36
+ return enum_for(:each) unless block
37
+ claim!
38
+ run(&block)
39
+ self
40
+ end
41
+
42
+ # External iteration: the next event, or StopIteration after the last.
43
+ # The socket stays open between calls, so call #close when done early.
44
+ def next
45
+ @mutex.synchronize do
46
+ unless @external
47
+ raise IOError, "stream already consumed" if @consumed
48
+ @consumed = true
49
+ @external = Enumerator.new { |y| run { |event| y << event } }
50
+ end
51
+ end
52
+ @external.next
53
+ end
54
+
55
+ # Closes the socket if a request is open, and marks the stream consumed.
56
+ # Idempotent. An abandoned enumerator fiber never runs its ensure, so this
57
+ # finishes the Net::HTTP session itself rather than relying on one.
58
+ def close
59
+ http = nil
60
+ @mutex.synchronize do
61
+ @consumed = true
62
+ @external = nil
63
+ http, @http = @http, nil
64
+ end
65
+ http.finish if http&.started?
66
+ nil
67
+ rescue IOError
68
+ nil
69
+ end
70
+
71
+ # Sends the request and yields each event; returns the StreamResult.
72
+ # Used directly by the block form of a stream call.
73
+ def run(&block)
74
+ @pipeline.call(@operation, @url, @body, on_start: ->(http) { @http = http }) do |response, phase, observe|
75
+ consume(response, phase, observe, &block)
76
+ end
77
+ end
78
+
79
+ private
80
+
81
+ def claim!
82
+ @mutex.synchronize do
83
+ raise IOError, "stream already consumed" if @consumed
84
+ @consumed = true
85
+ end
86
+ end
87
+
88
+ def consume(response, phase, observe, &block)
89
+ unless Refusal.media_type(response) == "text/event-stream"
90
+ raise TransportError.new(:malformed_response, "a 200 stream answered #{Refusal.media_type(response).inspect}")
91
+ end
92
+ # The deprecation hook runs after the headers, before the first event.
93
+ @served_version = observe.call(response)
94
+ phase.event_stream = true
95
+ decoder = SSEDecoder.new
96
+ response.read_body do |chunk|
97
+ decoder.feed(chunk).each { |frame| handle(frame, phase, &block) }
98
+ end
99
+ raise TransportError.new(:stream_ended_early, "the stream closed before its terminal event")
100
+ end
101
+
102
+ def handle(frame, phase, &block)
103
+ stream = @operation[:stream]
104
+ return unless stream[:events].key?(frame.event)
105
+ ending = stream[:ends][frame.event]
106
+ raise stream_error(frame.data) if ending == :raise
107
+ return finish(phase, frame) if ending == :quiet
108
+ event = Lingara.const_get(stream[:union]).decode(frame.event, frame.data)
109
+ return if event.nil?
110
+ phase.in_caller_block { block.call(event) }
111
+ phase.leave(StreamResult.new(served_version: @served_version)) if ending == :yield
112
+ end
113
+
114
+ # A Done payload: never yielded, but still the JSON it should be.
115
+ def finish(phase, frame)
116
+ JSON.parse(frame.data)
117
+ phase.leave(StreamResult.new(served_version: @served_version))
118
+ rescue JSON::ParserError
119
+ raise TransportError.new(:malformed_event, "#{frame.event}: data is not JSON")
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
+ end
135
+ end