Class: ShipReal::Client

Inherits:
Object
  • Object
show all
Defined in:
lib/shipreal.rb

Overview

Talks to the ShipReal API.

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(base_url: DEFAULT_BASE_URL, sandbox: false, timeout: 30) ⇒ Client

sandbox: routes reads at the frozen fixture data. Same code path and same shapes over contents that never change, so a test written against it stays green when the curriculum moves. Fixture prices are 1 unit and fixture links point at example.invalid, so sandbox data leaking into real output is obvious. See https://shipreal.dev/sandbox



63
64
65
66
67
# File 'lib/shipreal.rb', line 63

def initialize(base_url: DEFAULT_BASE_URL, sandbox: false, timeout: 30)
  @base_url = base_url.chomp("/")
  @sandbox = sandbox
  @timeout = timeout
end

Instance Attribute Details

#base_urlObject (readonly)

Returns the value of attribute base_url.



56
57
58
# File 'lib/shipreal.rb', line 56

def base_url
  @base_url
end

#sandboxObject (readonly)

Returns the value of attribute sandbox.



56
57
58
# File 'lib/shipreal.rb', line 56

def sandbox
  @sandbox
end

#timeoutObject (readonly)

Returns the value of attribute timeout.



56
57
58
# File 'lib/shipreal.rb', line 56

def timeout
  @timeout
end

Instance Method Details

#ask(query) ⇒ Object

Ask in natural language (NLWeb).

There is no model behind this: it runs the same keyword search, which means it says so when nothing matches instead of inventing a module.

Raises:

  • (ArgumentError)


134
135
136
137
138
# File 'lib/shipreal.rb', line 134

def ask(query)
  raise ArgumentError, "ask needs a question" if query.to_s.empty?

  request(:post, "#{@base_url}/ask", body: { query: query })
end

#ask_stream(query) ⇒ Object

The same question, streamed. Yields NLWeb events as they arrive: "start", then one "result" per hit, then "complete".

Raises:

  • (ArgumentError)


142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
# File 'lib/shipreal.rb', line 142

def ask_stream(query)
  raise ArgumentError, "ask_stream needs a question" if query.to_s.empty?
  return enum_for(:ask_stream, query) unless block_given?

  uri = URI("#{@base_url}/ask")
  req = Net::HTTP::Post.new(uri)
  req["accept"] = "text/event-stream"
  req["content-type"] = "application/json"
  req["user-agent"] = USER_AGENT
  req.body = JSON.generate({ query: query })

  http(uri).request(req) do |res|
    raise Error.new(res.code.to_i, safe_json(res.body), uri.to_s) unless res.is_a?(Net::HTTPSuccess)

    event = "message"
    data = +""
    res.read_body do |chunk|
      chunk.each_line do |raw|
        line = raw.chomp
        if line.empty?
          # A blank line closes an SSE frame. Anything short of one is a
          # partial frame and waits for the next line.
          unless data.empty?
            parsed = safe_json(data)
            yield({ "event" => event, "data" => parsed }) if parsed
          end
          event = "message"
          data = +""
        elsif line.start_with?("event:")
          event = line[6..].strip
        elsif line.start_with?("data:")
          data << line[5..].strip
        end
      end
    end
  end
end

#batch(requests) ⇒ Object

Several reads in one round trip, up to MAX_BATCH.

Each item comes back with its own status, so check per item rather than assuming the whole batch succeeded.

Raises:

  • (ArgumentError)


124
125
126
127
128
# File 'lib/shipreal.rb', line 124

def batch(requests)
  raise ArgumentError, "batch takes at most #{MAX_BATCH} requests" if requests.length > MAX_BATCH

  request(:post, "#{@base_url}/api/#{API_VERSION}/batch", body: { requests: requests })
end

#courseObject

Totals, language and the subtitle languages.



116
117
118
# File 'lib/shipreal.rb', line 116

def course
  get("/course")
end

#module_by(slug_or_title) ⇒ Object

One module by slug, or by an exact or partial title match.

Raises:

  • (ArgumentError)


92
93
94
95
96
# File 'lib/shipreal.rb', line 92

def module_by(slug_or_title)
  raise ArgumentError, "module_by needs a slug or title" if slug_or_title.to_s.empty?

  get("/modules/#{ERB_ESCAPE.call(slug_or_title.to_s)}")
end

#modules(query = nil) ⇒ Object

Every matching module, following pagination for you.



79
80
81
82
83
84
85
86
87
88
89
# File 'lib/shipreal.rb', line 79

def modules(query = nil)
  out = []
  cursor = nil
  loop do
    page = search(query: query, limit: 100, cursor: cursor)
    out.concat(page["data"] || [])
    cursor = page.dig("pagination", "nextCursor")
    break if cursor.nil? || cursor.empty?
  end
  out
end

#pricing(region: nil) ⇒ Object

Current plans and prices.

Two regional prices are live at once, so quoting one without naming its region is misleading. Pass region ("intl" or "il") when you know which applies and the response comes back flattened to it.



103
104
105
106
107
108
109
110
111
112
113
# File 'lib/shipreal.rb', line 103

def pricing(region: nil)
  every = get("/pricing")
  return every unless %w[intl il].include?(region)

  complete = every["complete"][region].dup
  complete["url"] = every["complete"]["url"]
  teams = every["teams"][region].dup
  teams["minSeats"] = every["teams"]["minSeats"]
  teams["perSeat"] = true
  { "region" => region, "free" => every["free"], "complete" => complete, "teams" => teams }
end

#search(query: nil, page: nil, limit: nil, cursor: nil) ⇒ Object

Search the curriculum. Without a query, every module in course order.

Matching is a case-insensitive substring over title, description and part name, so an empty result means the course does not cover that topic under that name, rather than that the search was too clever.



74
75
76
# File 'lib/shipreal.rb', line 74

def search(query: nil, page: nil, limit: nil, cursor: nil)
  get("/modules", q: query, page: page, limit: limit, cursor: cursor)
end