Python
Worked examples with Requests (pip install requests). 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.
If you'd rather have no dependencies at all, foudra.py in the tools does the same things with Python's standard library.
A session
Start every script with a session: it keeps the connection open between requests and sends the key with each one. Always give a timeout.
import os
import requests
BASE = os.environ["FOUDRA_URL"].rstrip("/")
api = requests.Session()
api.headers["Authorization"] = "Bearer " + os.environ["FOUDRA_API_KEY"]
def call(method, path, **kw):
"""One request; Foudra's error message, if it refuses, as the exception."""
r = api.request(method, BASE + path, timeout=60, **kw)
if not r.ok:
try:
message = r.json()["error"]
except ValueError:
message = r.text[:200]
raise RuntimeError(f"{method} {path}: {r.status_code} {message}")
return r.json() if r.content and r.headers.get("Content-Type", "").startswith("application/json") else r
The examples below use call.
Reading
part = call("GET", "/api/parts/1203")["data"]
print(part["part_number"], part["name"], part["on_hand"])
Lists come a page at a time. This generator walks them all:
def every(path, **params):
params.setdefault("per", 100)
page = 1
while True:
out = call("GET", path, params={**params, "page": page})
yield from out["data"]
if page >= out.get("pages", 1):
return
page += 1
for p in every("/api/parts", category="504"):
print(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):
for p in call("GET", "/api/parts", params={"q": number, "per": 10})["data"]:
if p["part_number"] == number:
return p
return None
Quantities and money
They arrive as strings, so nothing is lost on the way. Keep them exact with Decimal, and send them back as strings:
from decimal import Decimal
on_hand = Decimal(part["on_hand"])
if on_hand < Decimal(part["minimum_stock"]):
print("below minimum:", part["part_number"])
call("PUT", f"/api/bom/lines/{line_id}", json={"quantity": str(Decimal("0.0319"))})
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"]
print("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.
with open("bow_node-mount_COREONE.bgcode", "rb") as f:
doc = call("POST", f"/api/parts/{bracket['id']}/documents",
files={"file": f}, data={"type": "gcode", "revision": "B"})["data"]
print(doc["id"], doc["printer_model"]) # "COREONE", read from the file
with open("render.png", "rb") as f:
call("POST", f"/api/parts/{bracket['id']}/images", files={"file": f})
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:
import hashlib
def download(doc, path):
with api.get(BASE + doc["url"], stream=True, timeout=300) as r:
r.raise_for_status()
h = hashlib.sha256()
with open(path, "wb") as f:
for chunk in r.iter_content(1 << 16):
f.write(chunk)
h.update(chunk)
assert h.hexdigest() == doc["sha256"]
docs = call("GET", f"/api/parts/{bracket['id']}/documents")["data"]
gcode = [d for d in docs if d["type"] == "gcode" and d["printer_model"] == "COREONE"]
if gcode:
newest = max(gcode, key=lambda d: d["created_at"])
download(newest, "job.bgcode")
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).
import datetime
order = call("POST", "/api/work-orders",
headers={"Idempotency-Key": f"service:147:{datetime.date.today()}"},
json={"part_number": "950-000001-00", "location": "BHILL",
"quantity": "1", "external_ref": "vessel_id:147"})
print(order["summary"]) # a sentence fit to show a person
print(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.
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
api.mount(BASE, HTTPAdapter(max_retries=Retry(
total=5, backoff_factor=1, status_forcelist=[502, 503, 504],
allowed_methods=["GET", "PUT", "DELETE"])))