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, withIdempotent-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.