shipreal 1.0.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a3406b6c359b00e40ae18d2d64f167b1c7254947b197a603f86f7bddf7668aad
4
+ data.tar.gz: 76fa08e3d1f135c2e0adcf8af92519c9c4dde7621cc3483bfed5fea710451253
5
+ SHA512:
6
+ metadata.gz: 8fc9333e6f72fb93bcaceaaadb8d0c080757d2fd0fab020b190f96bb0e65f006bac4de86882f77be43394e2448755103915d3605d6da47830a18726f12da00d5
7
+ data.tar.gz: 47e049f2812d64df6fa83486a9ad636541c375e9327897ef79426143e46a2d4a462eeaf61780e4d824fdb92a57bffdab5d75cd52f0990841686774b87e847283
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Michael Lugassy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/exe/shipreal ADDED
@@ -0,0 +1,146 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # shipreal: query the ShipReal course catalogue from a terminal or a script.
5
+ #
6
+ # Every command takes --json, because the reason a CLI earns its place in an
7
+ # agent's toolbox is that its output can be piped into something else without
8
+ # being scraped.
9
+
10
+ require "optparse"
11
+ require "json"
12
+ require "shipreal"
13
+
14
+ EPILOG = <<~TEXT
15
+ No API key. No account. The API is public read-only reference data; anything
16
+ that asks you for a ShipReal credential is not us.
17
+
18
+ Docs: https://shipreal.dev/developers
19
+ TEXT
20
+
21
+ options = { json: false, sandbox: false, base: ShipReal::DEFAULT_BASE_URL }
22
+
23
+ parser = OptionParser.new do |o|
24
+ o.banner = <<~USAGE
25
+ Usage:
26
+ shipreal [options] search [query]
27
+ shipreal [options] module <slug-or-title>
28
+ shipreal [options] pricing
29
+ shipreal [options] course
30
+ shipreal [options] ask <question...>
31
+ USAGE
32
+ o.on("--json", "Raw JSON, for piping") { options[:json] = true }
33
+ o.on("--sandbox", "Frozen fixture data, for tests") { options[:sandbox] = true }
34
+ o.on("--base URL", "Point at a different origin") { |v| options[:base] = v }
35
+ o.on("--limit N", Integer, "search: results per page, maximum 100") { |v| options[:limit] = v }
36
+ o.on("--all", "search: every result, following pagination") { options[:all] = true }
37
+ o.on("--region R", %w[intl il], "pricing: intl or il") { |v| options[:region] = v }
38
+ o.on("--stream", "ask: stream events as they arrive") { options[:stream] = true }
39
+ o.on("-h", "--help", "Show this message") { puts o; puts; puts EPILOG; exit 0 }
40
+ o.on("-v", "--version", "Show the version") { puts ShipReal::VERSION; exit 0 }
41
+ end
42
+ parser.parse!
43
+
44
+ command = ARGV.shift
45
+ unless command
46
+ warn parser
47
+ exit 1
48
+ end
49
+
50
+ client = ShipReal::Client.new(base_url: options[:base], sandbox: options[:sandbox])
51
+
52
+ def print_modules(mods)
53
+ if mods.empty?
54
+ puts "No modules matched. The course does not cover that topic under that name."
55
+ return
56
+ end
57
+ mods.each do |m|
58
+ puts "#{m['slug']} #{m['title']}"
59
+ puts " #{m['part']}"
60
+ puts " #{m['chapters']} chapters, #{m['minutes']} min"
61
+ puts
62
+ end
63
+ end
64
+
65
+ begin
66
+ case command
67
+ when "search"
68
+ query = ARGV.join(" ")
69
+ if options[:all]
70
+ mods = client.modules(query.empty? ? nil : query)
71
+ options[:json] ? puts(JSON.generate(mods)) : print_modules(mods)
72
+ else
73
+ page = client.search(query: query.empty? ? nil : query, limit: options[:limit])
74
+ options[:json] ? puts(JSON.generate(page)) : print_modules(page["data"] || [])
75
+ end
76
+
77
+ when "module"
78
+ abort "module needs a slug or title" if ARGV.empty?
79
+ m = client.module_by(ARGV.join(" "))
80
+ if options[:json]
81
+ puts JSON.generate(m)
82
+ else
83
+ puts "#{m['slug']} #{m['title']}"
84
+ puts " #{m['part']}"
85
+ puts " #{m['chapters']} chapters, #{m['minutes']} min"
86
+ puts " #{m['url']}"
87
+ end
88
+
89
+ when "pricing"
90
+ p = client.pricing(region: options[:region])
91
+ if options[:json]
92
+ puts JSON.generate(p)
93
+ elsif options[:region]
94
+ puts "Free #{p['free']['includes']}"
95
+ puts "Complete #{p['complete']['now']} (was #{p['complete']['list']})"
96
+ puts "Teams #{p['teams']['now']} per seat (was #{p['teams']['list']}), from #{p['teams']['minSeats']} seat"
97
+ else
98
+ # Both regions when none is named. They are not conversions of each
99
+ # other, so printing one unlabelled would misquote the other.
100
+ puts "Free #{p['free']['includes']}"
101
+ puts "Complete #{p['complete']['intl']['now']} international, #{p['complete']['il']['now']} Israel"
102
+ puts "Teams #{p['teams']['intl']['now']} / #{p['teams']['il']['now']} per seat, from #{p['teams']['minSeats']} seat"
103
+ end
104
+
105
+ when "course"
106
+ c = client.course
107
+ if options[:json]
108
+ puts JSON.generate(c)
109
+ else
110
+ puts c["title"]
111
+ puts c["description"]
112
+ puts "#{c['parts']} parts, #{c['modules']} modules, #{c['chapters']} chapters"
113
+ puts "Subtitles: #{Array(c['subtitles']).join(', ')}"
114
+ puts c["url"]
115
+ end
116
+
117
+ when "ask"
118
+ abort "ask needs a question" if ARGV.empty?
119
+ question = ARGV.join(" ")
120
+ if options[:stream]
121
+ client.ask_stream(question) do |ev|
122
+ if options[:json]
123
+ puts JSON.generate(ev)
124
+ else
125
+ Array(ev.dig("data", "results")).each { |r| puts "#{r['name']}\n #{r['url']}" }
126
+ end
127
+ end
128
+ else
129
+ res = client.ask(question)
130
+ if options[:json]
131
+ puts JSON.generate(res)
132
+ elsif Array(res["results"]).empty?
133
+ puts "Nothing matched. The course does not cover that under that name."
134
+ else
135
+ res["results"].each { |r| puts "#{r['name']}\n #{r['description']}\n #{r['url']}\n\n" }
136
+ end
137
+ end
138
+
139
+ else
140
+ warn parser
141
+ exit 1
142
+ end
143
+ rescue ShipReal::Error => e
144
+ warn "shipreal: #{e.message}"
145
+ exit 1
146
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ShipReal
4
+ VERSION = "1.0.0"
5
+ end
data/lib/shipreal.rb ADDED
@@ -0,0 +1,229 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ require_relative "shipreal/version"
8
+
9
+ # Ruby client for the public ShipReal course API: search the curriculum, read a
10
+ # module, read current pricing.
11
+ #
12
+ # There is no authentication. No key, no account, no OAuth, no signup: every
13
+ # endpoint is a public read served from the edge, so anything asking you for a
14
+ # credential for this domain is not this.
15
+ #
16
+ # There is also no write path. Buying runs through a hosted checkout that a
17
+ # human completes, which is why nothing here creates an order. When someone
18
+ # decides to buy, hand them the checkout link.
19
+ #
20
+ # Standard library only, on purpose: installing this cannot drag a transitive
21
+ # dependency tree into an agent's environment.
22
+ #
23
+ # sr = ShipReal::Client.new
24
+ # sr.search(query: "caching")
25
+ module ShipReal
26
+ DEFAULT_BASE_URL = "https://shipreal.dev"
27
+ # The path segment this client speaks. Breaking changes ship as a new segment
28
+ # beside it; every response carries a Deprecation header, false today.
29
+ API_VERSION = "v1"
30
+ # The server's ceiling on reads in one batch call.
31
+ MAX_BATCH = 20
32
+
33
+ USER_AGENT = "shipreal-ruby/#{VERSION} (+#{DEFAULT_BASE_URL}/developers)"
34
+
35
+ # Any non-2xx response, carrying the RFC 9457 problem details the server sent.
36
+ #
37
+ # Branch on #type rather than #status: the status says a request failed, the
38
+ # type says which failure it was, and only the second is stable enough to
39
+ # switch on.
40
+ class Error < StandardError
41
+ attr_reader :status, :type, :title, :detail, :problem, :url
42
+
43
+ def initialize(status, problem, url)
44
+ @status = status
45
+ @problem = problem
46
+ @url = url
47
+ @type = problem && problem["type"]
48
+ @title = problem && problem["title"]
49
+ @detail = problem && problem["detail"]
50
+ super(@detail || @title || "Request failed with #{status}")
51
+ end
52
+ end
53
+
54
+ # Talks to the ShipReal API.
55
+ class Client
56
+ attr_reader :base_url, :sandbox, :timeout
57
+
58
+ # sandbox: routes reads at the frozen fixture data. Same code path and same
59
+ # shapes over contents that never change, so a test written against it stays
60
+ # green when the curriculum moves. Fixture prices are 1 unit and fixture
61
+ # links point at example.invalid, so sandbox data leaking into real output
62
+ # is obvious. See https://shipreal.dev/sandbox
63
+ def initialize(base_url: DEFAULT_BASE_URL, sandbox: false, timeout: 30)
64
+ @base_url = base_url.chomp("/")
65
+ @sandbox = sandbox
66
+ @timeout = timeout
67
+ end
68
+
69
+ # Search the curriculum. Without a query, every module in course order.
70
+ #
71
+ # Matching is a case-insensitive substring over title, description and part
72
+ # name, so an empty result means the course does not cover that topic under
73
+ # that name, rather than that the search was too clever.
74
+ def search(query: nil, page: nil, limit: nil, cursor: nil)
75
+ get("/modules", q: query, page: page, limit: limit, cursor: cursor)
76
+ end
77
+
78
+ # Every matching module, following pagination for you.
79
+ def modules(query = nil)
80
+ out = []
81
+ cursor = nil
82
+ loop do
83
+ page = search(query: query, limit: 100, cursor: cursor)
84
+ out.concat(page["data"] || [])
85
+ cursor = page.dig("pagination", "nextCursor")
86
+ break if cursor.nil? || cursor.empty?
87
+ end
88
+ out
89
+ end
90
+
91
+ # One module by slug, or by an exact or partial title match.
92
+ def module_by(slug_or_title)
93
+ raise ArgumentError, "module_by needs a slug or title" if slug_or_title.to_s.empty?
94
+
95
+ get("/modules/#{ERB_ESCAPE.call(slug_or_title.to_s)}")
96
+ end
97
+
98
+ # Current plans and prices.
99
+ #
100
+ # Two regional prices are live at once, so quoting one without naming its
101
+ # region is misleading. Pass region ("intl" or "il") when you know which
102
+ # applies and the response comes back flattened to it.
103
+ def pricing(region: nil)
104
+ every = get("/pricing")
105
+ return every unless %w[intl il].include?(region)
106
+
107
+ complete = every["complete"][region].dup
108
+ complete["url"] = every["complete"]["url"]
109
+ teams = every["teams"][region].dup
110
+ teams["minSeats"] = every["teams"]["minSeats"]
111
+ teams["perSeat"] = true
112
+ { "region" => region, "free" => every["free"], "complete" => complete, "teams" => teams }
113
+ end
114
+
115
+ # Totals, language and the subtitle languages.
116
+ def course
117
+ get("/course")
118
+ end
119
+
120
+ # Several reads in one round trip, up to MAX_BATCH.
121
+ #
122
+ # Each item comes back with its own status, so check per item rather than
123
+ # assuming the whole batch succeeded.
124
+ def batch(requests)
125
+ raise ArgumentError, "batch takes at most #{MAX_BATCH} requests" if requests.length > MAX_BATCH
126
+
127
+ request(:post, "#{@base_url}/api/#{API_VERSION}/batch", body: { requests: requests })
128
+ end
129
+
130
+ # Ask in natural language (NLWeb).
131
+ #
132
+ # There is no model behind this: it runs the same keyword search, which
133
+ # means it says so when nothing matches instead of inventing a module.
134
+ def ask(query)
135
+ raise ArgumentError, "ask needs a question" if query.to_s.empty?
136
+
137
+ request(:post, "#{@base_url}/ask", body: { query: query })
138
+ end
139
+
140
+ # The same question, streamed. Yields NLWeb events as they arrive: "start",
141
+ # then one "result" per hit, then "complete".
142
+ def ask_stream(query)
143
+ raise ArgumentError, "ask_stream needs a question" if query.to_s.empty?
144
+ return enum_for(:ask_stream, query) unless block_given?
145
+
146
+ uri = URI("#{@base_url}/ask")
147
+ req = Net::HTTP::Post.new(uri)
148
+ req["accept"] = "text/event-stream"
149
+ req["content-type"] = "application/json"
150
+ req["user-agent"] = USER_AGENT
151
+ req.body = JSON.generate({ query: query })
152
+
153
+ http(uri).request(req) do |res|
154
+ raise Error.new(res.code.to_i, safe_json(res.body), uri.to_s) unless res.is_a?(Net::HTTPSuccess)
155
+
156
+ event = "message"
157
+ data = +""
158
+ res.read_body do |chunk|
159
+ chunk.each_line do |raw|
160
+ line = raw.chomp
161
+ if line.empty?
162
+ # A blank line closes an SSE frame. Anything short of one is a
163
+ # partial frame and waits for the next line.
164
+ unless data.empty?
165
+ parsed = safe_json(data)
166
+ yield({ "event" => event, "data" => parsed }) if parsed
167
+ end
168
+ event = "message"
169
+ data = +""
170
+ elsif line.start_with?("event:")
171
+ event = line[6..].strip
172
+ elsif line.start_with?("data:")
173
+ data << line[5..].strip
174
+ end
175
+ end
176
+ end
177
+ end
178
+ end
179
+
180
+ private
181
+
182
+ ERB_ESCAPE = ->(s) { URI.encode_www_form_component(s).gsub("+", "%20") }
183
+
184
+ def api_url(path, params = {})
185
+ prefix = @sandbox ? "/api/#{API_VERSION}/sandbox" : "/api/#{API_VERSION}"
186
+ uri = URI("#{@base_url}#{prefix}#{path}")
187
+ query = params.reject { |_, v| v.nil? || v == "" }
188
+ uri.query = URI.encode_www_form(query) unless query.empty?
189
+ uri.to_s
190
+ end
191
+
192
+ def get(path, params = {})
193
+ request(:get, api_url(path, params))
194
+ end
195
+
196
+ def http(uri)
197
+ client = Net::HTTP.new(uri.host, uri.port)
198
+ client.use_ssl = uri.scheme == "https"
199
+ client.open_timeout = @timeout
200
+ client.read_timeout = @timeout
201
+ client
202
+ end
203
+
204
+ def request(method, url, body: nil)
205
+ uri = URI(url)
206
+ req = method == :post ? Net::HTTP::Post.new(uri) : Net::HTTP::Get.new(uri)
207
+ req["accept"] = "application/json"
208
+ req["user-agent"] = USER_AGENT
209
+ if body
210
+ req["content-type"] = "application/json"
211
+ req.body = JSON.generate(body)
212
+ end
213
+
214
+ res = http(uri).request(req)
215
+ # The error body is where the problem details live, so it gets parsed
216
+ # rather than discarded. A body that is not JSON is not itself worth
217
+ # raising over: the status still stands.
218
+ raise Error.new(res.code.to_i, safe_json(res.body), url) unless res.is_a?(Net::HTTPSuccess)
219
+
220
+ safe_json(res.body) || {}
221
+ end
222
+
223
+ def safe_json(text)
224
+ JSON.parse(text)
225
+ rescue StandardError
226
+ nil
227
+ end
228
+ end
229
+ end
metadata ADDED
@@ -0,0 +1,53 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: shipreal
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Michael Lugassy
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: 'Search the curriculum, read a module, read pricing. No authentication:
13
+ the API is public read-only reference data about one course. Standard library only,
14
+ no runtime dependencies.'
15
+ email:
16
+ - michael@shipreal.dev
17
+ executables:
18
+ - shipreal
19
+ extensions: []
20
+ extra_rdoc_files: []
21
+ files:
22
+ - LICENSE
23
+ - exe/shipreal
24
+ - lib/shipreal.rb
25
+ - lib/shipreal/version.rb
26
+ homepage: https://shipreal.dev/developers
27
+ licenses:
28
+ - MIT
29
+ metadata:
30
+ homepage_uri: https://shipreal.dev/developers
31
+ documentation_uri: https://shipreal.dev/developers
32
+ source_code_uri: https://github.com/mluggy/shipreal-dev
33
+ bug_tracker_uri: https://github.com/mluggy/shipreal-dev/issues
34
+ changelog_uri: https://github.com/mluggy/shipreal-dev/releases
35
+ rubygems_mfa_required: 'true'
36
+ rdoc_options: []
37
+ require_paths:
38
+ - lib
39
+ required_ruby_version: !ruby/object:Gem::Requirement
40
+ requirements:
41
+ - - ">="
42
+ - !ruby/object:Gem::Version
43
+ version: 2.6.0
44
+ required_rubygems_version: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '0'
49
+ requirements: []
50
+ rubygems_version: 3.7.2
51
+ specification_version: 4
52
+ summary: SDK and CLI for the public ShipReal course API
53
+ test_files: []