sailing Foudra MRP

Ruby

Worked examples with Ruby's standard library (net/http and json), so there is nothing to install; Ruby 2.7 or later. Each one is complete: set FOUDRA_URL and FOUDRA_API_KEY as in API keys and run it. Every endpoint is listed in the reference.

They are the same examples as the Python page, so if you prefer Faraday or HTTParty, the requests are the same: only the plumbing changes.

A connection

Open one connection and keep it for every request; send the key with each one. Always give a timeout.

require "json"
require "net/http"

BASE = URI(ENV.fetch("FOUDRA_URL"))
KEY = ENV.fetch("FOUDRA_API_KEY")
HTTP = Net::HTTP.start(BASE.host, BASE.port, use_ssl: BASE.scheme == "https",
                       open_timeout: 10, read_timeout: 60)

class FoudraError < StandardError
  attr_reader :status

  def initialize(message, status)
    super(message)
    @status = status
  end
end

# One request; Foudra's error message, if it refuses, as the exception.
def call(method, path, params: nil, json: nil, form: nil, headers: {})
  uri = URI.join(BASE, path)
  uri.query = URI.encode_www_form(params) if params
  req = Net::HTTP.const_get(method.capitalize).new(uri, headers)
  req["Authorization"] = "Bearer #{KEY}"
  if json
    req["Content-Type"] = "application/json"
    req.body = JSON.generate(json)
  elsif form
    req.set_form(form, "multipart/form-data")
  end
  res = HTTP.request(req)
  body = res["Content-Type"].to_s.start_with?("application/json") ? JSON.parse(res.body) : res.body
  unless res.is_a?(Net::HTTPSuccess)
    message = body.is_a?(Hash) ? body["error"] : res.body.to_s[0, 200]
    raise FoudraError.new("#{method} #{path}: #{res.code} #{message}", res.code.to_i)
  end
  body
end

The examples below use call.

Reading

part = call("GET", "/api/parts/1203")["data"]
puts part.values_at("part_number", "name", "on_hand").join(" ")

Lists come a page at a time. This method walks them all, and without a block returns an enumerator, so every(...).find stops reading as soon as it has its answer:

def every(path, **params)
  return enum_for(:every, path, **params) unless block_given?

  params = { per: 100 }.merge(params)
  page = 1
  loop do
    out = call("GET", path, params: params.merge(page: page))
    out["data"].each { |row| yield row }
    break if page >= out.fetch("pages", 1)

    page += 1
  end
end

every("/api/parts", category: "504") { |p| puts "#{p["part_number"]} #{p["name"]}" }

?q= searches part numbers, names and descriptions. A part number search can match more than one part, so compare:

def find_part(number)
  call("GET", "/api/parts", params: { q: number, per: 10 })["data"].find { |p| p["part_number"] == number }
end

Quantities and money

They arrive as strings, so nothing is lost on the way. Keep them exact with BigDecimal, and send them back as strings. to_s("F") writes 0.0319; plain to_s would write 0.319e-1.

require "bigdecimal"

on_hand = BigDecimal(part["on_hand"])
puts "below minimum: #{part["part_number"]}" if on_hand < BigDecimal(part["minimum_stock"])

call("PUT", "/api/bom/lines/#{line_id}", json: { quantity: BigDecimal("0.0319").to_s("F") })

From Ruby 3.4, bigdecimal is a gem of its own: add gem "bigdecimal" to your Gemfile.

Creating

bracket = call("POST", "/api/parts", json: {
  category_code: "504",
  name: "Bow node mount",
  part_type: "sub_assembly",
  description: "3D printed, PETG",
  custom: { source: "my-script" } # yours to use; Foudra keeps it and never reads it
})["data"]
puts "made #{bracket["part_number"]}"

The stock number is given out by Foudra in the category; the answer is the new part.

Uploading files

Images and documents are multipart uploads: the file in the field file, and for a document its type and, if you like, a title and revision. An open File is sent under its own name.

doc = File.open("bow_node-mount_COREONE.bgcode", "rb") do |f|
  call("POST", "/api/parts/#{bracket["id"]}/documents",
       form: [["file", f], ["type", "gcode"], ["revision", "B"]])["data"]
end
puts "#{doc["id"]} #{doc["printer_model"]}" # "COREONE", read from the file

File.open("render.png", "rb") do |f|
  call("POST", "/api/parts/#{bracket["id"]}/images", form: [["file", f]])
end

Foudra decides what a file is from its contents, so it must really be what its name says. The documents page lists the formats and sizes.

Downloading files

A document or image in the lists has a url; fetch it with the same key. Stream it to disk, since a file can be up to 50 MB, and compare its SHA-256 if you keep copies, so you only download what has changed:

require "digest"

def download(doc, path)
  sha = Digest::SHA256.new
  req = Net::HTTP::Get.new(URI.join(BASE, doc["url"]))
  req["Authorization"] = "Bearer #{KEY}"
  HTTP.request(req) do |res|
    res.value # raises unless it's a 2xx
    File.open(path, "wb") do |f|
      res.read_body do |chunk|
        f.write(chunk)
        sha << chunk
      end
    end
  end
  raise "#{path}: SHA-256 doesn't match" unless sha.hexdigest == doc["sha256"]
end

docs = call("GET", "/api/parts/#{bracket["id"]}/documents")["data"]
gcode = docs.select { |d| d["type"] == "gcode" && d["printer_model"] == "COREONE" }
download(gcode.max_by { |d| d["created_at"] }, "job.bgcode") unless gcode.empty?

That is how a print manager picks the file for the printer it's about to use: a part can hold G-code for several printers, and each says which it was sliced for.

Doing things safely

Actions such as raising a work order take two extra headers and a query parameter:

  • Idempotency-Key: send a unique value per intended action. If the network fails and you send it again, Foudra returns the first answer, with Idempotent-Replay: true, instead of doing it twice.
  • ?dry_run=1: do everything, report what happened, then undo it. Use it to show a person what's about to happen.
  • X-On-Behalf-Of: act as another user (see API keys).
require "date"

order = call("POST", "/api/work-orders",
             headers: { "Idempotency-Key" => "service:147:#{Date.today}" },
             json: { part_number: "950-000001-00", location: "BHILL",
                     quantity: "1", external_ref: "vessel_id:147" })
puts order["summary"] # a sentence fit to show a person
puts order["data"]["number"]

Retrying

Retry when the network fails, or on 502, 503 and 504, with a pause that grows each time. Never retry a 4xx: the request was refused and will be again. A POST that creates something is only safe to retry if it carries an Idempotency-Key; the rest (GET, PUT, DELETE) are safe as they are.

def call_with_retries(method, path, tries: 5, **kw)
  safe = %w[GET PUT DELETE].include?(method) || kw.fetch(:headers, {}).key?("Idempotency-Key")
  attempt = 0
  begin
    call(method, path, **kw)
  rescue FoudraError, IOError, SystemCallError, Timeout::Error => e
    attempt += 1
    failed_on_the_way = !e.is_a?(FoudraError) || [502, 503, 504].include?(e.status)
    raise unless safe && failed_on_the_way && attempt < tries

    sleep 2**attempt
    retry
  end
end

A form with a File in it can't be sent twice, since the first attempt read the file; open it again for each attempt if you retry uploads.