notebooklm 4.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/README.md +155 -0
- data/lib/notebooklm/client.rb +154 -0
- data/lib/notebooklm/ergonomics.rb +80 -0
- data/lib/notebooklm/errors.rb +80 -0
- data/lib/notebooklm/generated.rb +2068 -0
- data/lib/notebooklm/model.rb +109 -0
- data/lib/notebooklm/version.rb +8 -0
- data/lib/notebooklm.rb +34 -0
- data/notebooklm.gemspec +33 -0
- metadata +61 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: e55f36aa3d5bc491e1c271b662aa8897b9629c3e15dbfd9468d9d9016ed2cb7e
|
|
4
|
+
data.tar.gz: e42aaea281852a835e1929d9de0493cb50c7772667c2a32b292402eaf2377f44
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 56dcb0bacd5ed50bcd92037614d4634e6ca8c3afb1f313abbeaa619672962abd9a35d8bedcdb0f9f6a025e9e254620f7eb581f74ceab16faa50710f69e8b7f90
|
|
7
|
+
data.tar.gz: 1f9d912626bd6165e6f2caf120148e2a7d63ba330774a9a2bafbdfee38302b6ceb8d48160e8237c3fa2d64bdc2889db292b4d3c44482e9bab088ec0977b511e0
|
data/README.md
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# notebooklm
|
|
2
|
+
|
|
3
|
+
[](https://rubygems.org/gems/notebooklm)
|
|
4
|
+
[](https://github.com/roomi-fields/notebooklm-mcp/actions/workflows/ci.yml)
|
|
5
|
+
[](https://opensource.org/licenses/MIT)
|
|
6
|
+
|
|
7
|
+
A Ruby client for **Gemini Notebook** (formerly NotebookLM): citation-backed
|
|
8
|
+
answers, passage retrieval, Studio generation, and the whole of a local
|
|
9
|
+
server's REST API as documented methods rather than hashes you have to guess at.
|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
|
|
13
|
+
- **All 56 operations**, generated from the server's OpenAPI specification, so
|
|
14
|
+
a route added upstream reaches this gem on the release that ships it —
|
|
15
|
+
rather than whenever someone remembers to add a method.
|
|
16
|
+
- **Every shape documented**: each field the specification describes has a
|
|
17
|
+
reader with its type, and a field the server adds later is still reachable
|
|
18
|
+
through `[]` and `to_h`. An unknown shape is never a wall.
|
|
19
|
+
- **Citation-backed answers**: `ask` returns the answer and the quoted passage
|
|
20
|
+
behind every marker.
|
|
21
|
+
- **Retrieval that spends no allowance**: `search_passages` returns the indexed
|
|
22
|
+
passages for a query without generating anything.
|
|
23
|
+
- **The indexed text itself**: `read_source` returns what the service actually
|
|
24
|
+
extracted from a PDF or a page, which the web interface only shows in
|
|
25
|
+
fragments.
|
|
26
|
+
- **Studio generation** — audio, video, infographic, report, presentation, data
|
|
27
|
+
table — plus study aids, mind maps, labels and notes.
|
|
28
|
+
- **The UTCP manual**: `get_utcp_manual` returns the document a
|
|
29
|
+
[UTCP](https://www.utcp.io/) client reads to call the API with no client at
|
|
30
|
+
all.
|
|
31
|
+
- **Honest absence**: a passage with no offset reads as `nil`, never as `0`.
|
|
32
|
+
Guessing a zero is how a quotation gets attributed to the top of a long
|
|
33
|
+
document.
|
|
34
|
+
- **A refusal keeps its words**: the server's own explanation travels in
|
|
35
|
+
`NotebookLM::Refused`, and a request that never arrived raises a different
|
|
36
|
+
class from one that was declined — `error.transport?` separates the two.
|
|
37
|
+
- **No runtime dependencies.** Net::HTTP and JSON, nothing else. The tests have
|
|
38
|
+
no dependencies either.
|
|
39
|
+
|
|
40
|
+
## Usage
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
# Gemfile
|
|
44
|
+
gem 'notebooklm', '~> 4.7'
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
or `gem install notebooklm`.
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
require 'notebooklm'
|
|
51
|
+
|
|
52
|
+
client = NotebookLM.local
|
|
53
|
+
|
|
54
|
+
unless client.get_health.authenticated
|
|
55
|
+
abort 'no Google session yet — run the setup command once'
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
notebook = client.list_notebooks.notebooks.first
|
|
59
|
+
|
|
60
|
+
# Retrieval: the evidence, with no generation allowance spent.
|
|
61
|
+
client.search_passages(id: notebook.id, query: 'ikigai', limit: 5).passages.each do |passage|
|
|
62
|
+
puts "#{passage.rank} — #{passage.text}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Or an answer, with the quotations behind it.
|
|
66
|
+
answer = client.ask(notebook.id, 'How is ikigai defined?')
|
|
67
|
+
puts answer.answer
|
|
68
|
+
answer.citations.each { |citation| puts " [#{citation.number}] #{citation.source_text}" }
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Every operation takes its parameters as keyword arguments under the names the
|
|
72
|
+
specification gives them, and returns a shape whose readers are documented:
|
|
73
|
+
|
|
74
|
+
```ruby
|
|
75
|
+
client.ask_question(question: 'How is ikigai defined?', notebook_id: notebook.id,
|
|
76
|
+
source_format: 'json')
|
|
77
|
+
client.generate_report(id: notebook.id, prompt: 'A literature review')
|
|
78
|
+
client.get_utcp_manual.tools.size # => 56
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## It needs a server, and that is deliberate
|
|
82
|
+
|
|
83
|
+
Google publishes no API for the consumer product. Everything any tool does
|
|
84
|
+
rides on the endpoints the web application itself calls, and those require a
|
|
85
|
+
logged-in Google session. Keeping one means three things that are easy to
|
|
86
|
+
underestimate:
|
|
87
|
+
|
|
88
|
+
- the session is **bound to the machine** it was created on — copied cookies
|
|
89
|
+
are refused even while their local expiry still looks valid;
|
|
90
|
+
- Google **expires sessions server-side** while those cookies look fine, so the
|
|
91
|
+
only reliable check is navigating and seeing where you land;
|
|
92
|
+
- an unpatched automated browser **gets flagged**, and the patches have to be
|
|
93
|
+
maintained against a moving target.
|
|
94
|
+
|
|
95
|
+
So this gem owns no browser. It talks to a process that does:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
npx -y @roomi-fields/notebooklm-mcp # the server
|
|
99
|
+
npx -y -p @roomi-fields/notebooklm-mcp notebooklm-mcp-setup-auth # once
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
On loopback the server needs no token. As soon as you expose it, it refuses to
|
|
103
|
+
start without one:
|
|
104
|
+
|
|
105
|
+
```ruby
|
|
106
|
+
client = NotebookLM::Client.new(base: 'http://10.0.0.2:3000', api_key: ENV.fetch('NOTEBOOKLM_API_KEY'))
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`NOTEBOOKLM_API_KEY` and `NOTEBOOKLM_BASE_URL` are read from the environment
|
|
110
|
+
when you pass neither. The key is never printed: `inspect` redacts it.
|
|
111
|
+
|
|
112
|
+
## Errors
|
|
113
|
+
|
|
114
|
+
```ruby
|
|
115
|
+
begin
|
|
116
|
+
client.ask(notebook.id, 'How is ikigai defined?')
|
|
117
|
+
rescue NotebookLM::Refused => e
|
|
118
|
+
warn e.reason # the server's own words, for a human
|
|
119
|
+
rescue NotebookLM::TransportError => e
|
|
120
|
+
retry if (tries += 1) < 3 # nothing arrived; retrying can work
|
|
121
|
+
rescue NotebookLM::ShapeError => e
|
|
122
|
+
warn "client and server disagree on #{e.route}"
|
|
123
|
+
end
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
`NotebookLM::EmptyPayload` means the server reported success and sent nothing —
|
|
127
|
+
its contract breach, reported rather than returned as a `nil` for you to trip
|
|
128
|
+
over three lines later.
|
|
129
|
+
|
|
130
|
+
## Versions
|
|
131
|
+
|
|
132
|
+
The gem version is the server version it speaks to. There is one release per
|
|
133
|
+
server release, and 4.6 works against a 4.6 server; a mismatch is the one thing
|
|
134
|
+
this gem cannot check for you.
|
|
135
|
+
|
|
136
|
+
Ruby **3.1** or later.
|
|
137
|
+
|
|
138
|
+
## Documentation
|
|
139
|
+
|
|
140
|
+
- [Server documentation](https://notebooklm.roomi-fields.com/)
|
|
141
|
+
- [Driving it from Ruby](https://notebooklm.roomi-fields.com/blog/notebooklm-ruby)
|
|
142
|
+
— why the gem is shaped this way
|
|
143
|
+
- [REST API reference](https://notebooklm.roomi-fields.com/notebooklm-rest-api)
|
|
144
|
+
|
|
145
|
+
## License
|
|
146
|
+
|
|
147
|
+
MIT. See [LICENSE](https://github.com/roomi-fields/notebooklm-mcp/blob/main/LICENSE).
|
|
148
|
+
|
|
149
|
+
### Contribution
|
|
150
|
+
|
|
151
|
+
Issues and pull requests go to
|
|
152
|
+
[roomi-fields/notebooklm-mcp](https://github.com/roomi-fields/notebooklm-mcp),
|
|
153
|
+
which holds the server and this gem together. The surface is generated: change
|
|
154
|
+
`deployment/docs/openapi.yaml` and run `npm run ruby:sync`, never
|
|
155
|
+
`lib/notebooklm/generated.rb` by hand.
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'net/http'
|
|
5
|
+
require 'uri'
|
|
6
|
+
|
|
7
|
+
module NotebookLM
|
|
8
|
+
# A client for one local server.
|
|
9
|
+
#
|
|
10
|
+
# Every operation lives in {Operations}, generated from the server's OpenAPI
|
|
11
|
+
# specification. This class is only the plumbing underneath: where to send a
|
|
12
|
+
# request, how to carry the token, and how to turn the envelope into either a
|
|
13
|
+
# shape or an error.
|
|
14
|
+
#
|
|
15
|
+
# @example
|
|
16
|
+
# client = NotebookLM::Client.new
|
|
17
|
+
# client.get_health.authenticated # => true
|
|
18
|
+
class Client
|
|
19
|
+
# Where the server listens when nothing says otherwise.
|
|
20
|
+
DEFAULT_BASE = 'http://127.0.0.1:3000'
|
|
21
|
+
|
|
22
|
+
# A question can legitimately take minutes, and Net::HTTP would otherwise
|
|
23
|
+
# give up after sixty seconds — reporting a timeout for a call that was
|
|
24
|
+
# working.
|
|
25
|
+
DEFAULT_READ_TIMEOUT = 300
|
|
26
|
+
|
|
27
|
+
# @return [String] the address this client talks to
|
|
28
|
+
attr_reader :base
|
|
29
|
+
|
|
30
|
+
# @param base [String] where the server listens
|
|
31
|
+
# @param api_key [String, nil] the token the server requires as soon as it
|
|
32
|
+
# is exposed beyond loopback. Defaults to `NOTEBOOKLM_API_KEY`, and is
|
|
33
|
+
# never printed by {#inspect}.
|
|
34
|
+
# @param read_timeout [Numeric] how long to wait for one answer
|
|
35
|
+
# @param open_timeout [Numeric] how long to wait for the connection
|
|
36
|
+
def initialize(base: nil, api_key: :from_env, read_timeout: DEFAULT_READ_TIMEOUT,
|
|
37
|
+
open_timeout: 10)
|
|
38
|
+
@base = base || ENV['NOTEBOOKLM_BASE_URL'] || DEFAULT_BASE
|
|
39
|
+
@api_key = api_key == :from_env ? ENV.fetch('NOTEBOOKLM_API_KEY', nil) : api_key
|
|
40
|
+
raise ApiKeyError if @api_key && @api_key.match?(/[\u0000-\u001f\u007f]/)
|
|
41
|
+
|
|
42
|
+
@uri = parse_base(@base)
|
|
43
|
+
@read_timeout = read_timeout
|
|
44
|
+
@open_timeout = open_timeout
|
|
45
|
+
@mutex = Mutex.new
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The api key is held here, so a printed client would leak it.
|
|
49
|
+
def inspect
|
|
50
|
+
"#<#{self.class.name} base=#{@base.inspect} api_key=#{@api_key ? '<redacted>' : 'nil'}>"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# One request, for the generated surface: the route, an optional body, an
|
|
54
|
+
# optional query block, and the envelope unwrapped.
|
|
55
|
+
#
|
|
56
|
+
# `route` is only ever used to say which call could not be read, which is
|
|
57
|
+
# the difference between "the server changed" and "my code is wrong".
|
|
58
|
+
#
|
|
59
|
+
# @api private
|
|
60
|
+
def call(route, method, path, body: nil, query: nil, enveloped: true)
|
|
61
|
+
response = perform(route, method, path, body, query)
|
|
62
|
+
payload = parse(route, response)
|
|
63
|
+
return payload unless enveloped
|
|
64
|
+
|
|
65
|
+
unwrap(route, payload)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def parse_base(base)
|
|
71
|
+
uri = URI.parse(base)
|
|
72
|
+
raise AddressError, "invalid server address: #{base}" unless uri.is_a?(URI::HTTP)
|
|
73
|
+
|
|
74
|
+
uri
|
|
75
|
+
rescue URI::InvalidURIError => e
|
|
76
|
+
raise AddressError, "invalid server address: #{base} (#{e.message})"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
METHODS = {
|
|
80
|
+
get: Net::HTTP::Get,
|
|
81
|
+
post: Net::HTTP::Post,
|
|
82
|
+
put: Net::HTTP::Put,
|
|
83
|
+
patch: Net::HTTP::Patch,
|
|
84
|
+
delete: Net::HTTP::Delete
|
|
85
|
+
}.freeze
|
|
86
|
+
private_constant :METHODS
|
|
87
|
+
|
|
88
|
+
def perform(route, method, path, body, query)
|
|
89
|
+
target = route_uri(path, query)
|
|
90
|
+
request = METHODS.fetch(method).new(target)
|
|
91
|
+
request['Accept'] = 'application/json'
|
|
92
|
+
request['User-Agent'] = "notebooklm-ruby/#{VERSION}"
|
|
93
|
+
request['Authorization'] = "Bearer #{@api_key}" if @api_key
|
|
94
|
+
if body
|
|
95
|
+
request['Content-Type'] = 'application/json'
|
|
96
|
+
request.body = JSON.generate(body)
|
|
97
|
+
end
|
|
98
|
+
send_request(request)
|
|
99
|
+
rescue KeyError
|
|
100
|
+
raise ShapeError.new(route, "no such HTTP method: #{method}")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def send_request(request)
|
|
104
|
+
@mutex.synchronize { http.request(request) }
|
|
105
|
+
rescue StandardError => e
|
|
106
|
+
raise TransportError.new(@base, e)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# One connection, reused. Net::HTTP is not thread-safe, so the mutex above
|
|
110
|
+
# serialises calls rather than pretending otherwise.
|
|
111
|
+
def http
|
|
112
|
+
return @http if @http&.started?
|
|
113
|
+
|
|
114
|
+
@http = Net::HTTP.new(@uri.host, @uri.port)
|
|
115
|
+
@http.use_ssl = @uri.scheme == 'https'
|
|
116
|
+
@http.read_timeout = @read_timeout
|
|
117
|
+
@http.open_timeout = @open_timeout
|
|
118
|
+
@http.start
|
|
119
|
+
@http
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def route_uri(path, query)
|
|
123
|
+
uri = @uri.merge(path)
|
|
124
|
+
pairs = (query || {}).reject { |_, value| value.nil? }
|
|
125
|
+
uri.query = URI.encode_www_form(pairs) unless pairs.empty?
|
|
126
|
+
uri
|
|
127
|
+
rescue URI::Error => e
|
|
128
|
+
raise AddressError, "could not join #{path} onto #{@base} (#{e.message})"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def parse(route, response)
|
|
132
|
+
body = response.body.to_s
|
|
133
|
+
raise ShapeError.new(route, "the server answered #{response.code} with an empty body") if body.empty?
|
|
134
|
+
|
|
135
|
+
JSON.parse(body)
|
|
136
|
+
rescue JSON::ParserError => e
|
|
137
|
+
raise ShapeError.new(route, e.message)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# The envelope carries the refusal, so the HTTP status is not consulted: a
|
|
141
|
+
# refused call answers 4xx *and* `success: false`, and the body is the half
|
|
142
|
+
# that says why.
|
|
143
|
+
def unwrap(route, payload)
|
|
144
|
+
raise ShapeError.new(route, 'the answer was not a JSON object') unless payload.is_a?(Hash)
|
|
145
|
+
|
|
146
|
+
data = payload['data']
|
|
147
|
+
reason = payload['error']
|
|
148
|
+
return data if payload['success'] == true && !data.nil?
|
|
149
|
+
raise Refused, reason.to_s unless reason.nil?
|
|
150
|
+
|
|
151
|
+
raise EmptyPayload, route
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NotebookLM
|
|
4
|
+
# How the quotations behind an answer should come back.
|
|
5
|
+
#
|
|
6
|
+
# The wire takes a string; this is the list the server accepts, so a typo
|
|
7
|
+
# raises here instead of coming back as an answer with no citations.
|
|
8
|
+
SOURCE_FORMATS = %w[none json inline footnotes expanded].freeze
|
|
9
|
+
|
|
10
|
+
# The few helpers the generated surface cannot have.
|
|
11
|
+
#
|
|
12
|
+
# Everything else in this gem comes out of the OpenAPI specification, which
|
|
13
|
+
# is what keeps it complete. A generator cannot know that a question usually
|
|
14
|
+
# wants its quotations as data, or that the session id of one answer is the
|
|
15
|
+
# handle for the next — so those live here.
|
|
16
|
+
#
|
|
17
|
+
# There are fewer of these than the Rust crate needs, and that is Ruby's
|
|
18
|
+
# doing: keyword arguments with defaults already read like a builder, so a
|
|
19
|
+
# request object would add nothing.
|
|
20
|
+
module Ergonomics
|
|
21
|
+
# Ask a notebook in the local library, with the quotations returned as
|
|
22
|
+
# data. The long form is {Operations#ask_question}.
|
|
23
|
+
#
|
|
24
|
+
# @param notebook_id [String] a notebook id from {Operations#list_notebooks}
|
|
25
|
+
# @param question [String] the question, in prose
|
|
26
|
+
# @param session_id [String, nil] continue an earlier chat instead of
|
|
27
|
+
# starting a new one — pass the `session_id` the previous answer carried
|
|
28
|
+
# @param source_format [String] one of {SOURCE_FORMATS}
|
|
29
|
+
# @return [AskQuestionData]
|
|
30
|
+
def ask(notebook_id, question, session_id: nil, source_format: 'json')
|
|
31
|
+
check_source_format(source_format)
|
|
32
|
+
ask_question(
|
|
33
|
+
question: question,
|
|
34
|
+
notebook_id: notebook_id,
|
|
35
|
+
session_id: session_id,
|
|
36
|
+
source_format: source_format
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The same question, against a notebook that is not in the library.
|
|
41
|
+
#
|
|
42
|
+
# @param notebook_url [String] the notebook's address
|
|
43
|
+
# @param question [String] the question, in prose
|
|
44
|
+
# @param session_id [String, nil] an earlier chat to continue
|
|
45
|
+
# @param source_format [String] one of {SOURCE_FORMATS}
|
|
46
|
+
# @return [AskQuestionData]
|
|
47
|
+
def ask_url(notebook_url, question, session_id: nil, source_format: 'json')
|
|
48
|
+
check_source_format(source_format)
|
|
49
|
+
ask_question(
|
|
50
|
+
question: question,
|
|
51
|
+
notebook_url: notebook_url,
|
|
52
|
+
session_id: session_id,
|
|
53
|
+
source_format: source_format
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def check_source_format(format)
|
|
60
|
+
return if SOURCE_FORMATS.include?(format.to_s)
|
|
61
|
+
|
|
62
|
+
raise ArgumentError,
|
|
63
|
+
"source_format must be one of #{SOURCE_FORMATS.join(', ')}, got #{format.inspect}"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
class AskQuestionData
|
|
68
|
+
# The quotations behind this answer, or an empty list when the question
|
|
69
|
+
# did not ask for them. Saves callers one hop through `sources`.
|
|
70
|
+
#
|
|
71
|
+
# @return [Array<AskQuestionDataSourcesCitationsItem>]
|
|
72
|
+
def citations
|
|
73
|
+
sources.citations
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
class Client
|
|
78
|
+
include Ergonomics
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NotebookLM
|
|
4
|
+
# What can go wrong between a Ruby program and the local server.
|
|
5
|
+
#
|
|
6
|
+
# The subclasses separate three situations a single error class usually
|
|
7
|
+
# blurs: the request never arrived, the server refused the request and said
|
|
8
|
+
# why, and the server answered in a shape we cannot read. Only the second is
|
|
9
|
+
# the caller's fault, and only the third is ours.
|
|
10
|
+
class Error < StandardError
|
|
11
|
+
# True when retrying could plausibly succeed. A refusal is not retryable:
|
|
12
|
+
# the server already decided.
|
|
13
|
+
def transport?
|
|
14
|
+
false
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# The request never completed: nothing listening, a timeout, a broken pipe.
|
|
19
|
+
class TransportError < Error
|
|
20
|
+
# @return [String] the address the client was pointed at
|
|
21
|
+
attr_reader :base
|
|
22
|
+
|
|
23
|
+
# @return [StandardError] what the HTTP stack reported
|
|
24
|
+
attr_reader :cause_error
|
|
25
|
+
|
|
26
|
+
def initialize(base, cause_error)
|
|
27
|
+
@base = base
|
|
28
|
+
@cause_error = cause_error
|
|
29
|
+
super("the server at #{base} could not be reached: #{cause_error.message}")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def transport?
|
|
33
|
+
true
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The server answered the envelope with `success: false` and a reason. The
|
|
38
|
+
# reason is the server's own wording, meant to be shown to a human.
|
|
39
|
+
class Refused < Error
|
|
40
|
+
# @return [String] the server's reason
|
|
41
|
+
attr_reader :reason
|
|
42
|
+
|
|
43
|
+
def initialize(reason)
|
|
44
|
+
@reason = reason
|
|
45
|
+
super("the server refused the request: #{reason}")
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The server reported success but sent no payload — a contract breach on its
|
|
50
|
+
# side, reported rather than returned as nil for the caller to trip over.
|
|
51
|
+
class EmptyPayload < Error
|
|
52
|
+
def initialize(route)
|
|
53
|
+
super("the server reported success with no payload for #{route}")
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The body did not match the shape this version of the client expects, which
|
|
58
|
+
# usually means the client and the server are different versions.
|
|
59
|
+
class ShapeError < Error
|
|
60
|
+
# @return [String] which call could not be read
|
|
61
|
+
attr_reader :route
|
|
62
|
+
|
|
63
|
+
def initialize(route, detail)
|
|
64
|
+
@route = route
|
|
65
|
+
super("could not read the server's answer to #{route}: #{detail}")
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The base address could not be parsed, or a route could not be joined onto it.
|
|
70
|
+
class AddressError < Error; end
|
|
71
|
+
|
|
72
|
+
# The api key held a byte that cannot travel in an HTTP header. Caught here
|
|
73
|
+
# rather than sent, because a stray newline in a header is a request
|
|
74
|
+
# smuggling primitive, not a typo.
|
|
75
|
+
class ApiKeyError < Error
|
|
76
|
+
def initialize
|
|
77
|
+
super('the api key cannot be sent as a header: it holds a newline or a control byte')
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|