REST API Client¶
Define your APIs in YAML, call them from CLI or Python. Pure declarative.
TL;DR¶
from kstlib.rapi import RapiClient, call
# Simple GET (uses kstlib.conf.yml config)
response = call("github.user")
print(response.data) # {'login': 'octocat', ...}
# With path parameters
response = call("github.repos-issues", owner="KaminoU", repo="igcv3")
# Query params override YAML defaults
response = call("github.repos-list", per_page="50", page="2")
# POST with JSON body
response = call("myapi.create", body={"name": "test", "value": 42})
# Async support
response = await call_async("github.user")
# External config file
client = RapiClient.from_file("github.rapi.yml")
response = client.call("user")
CLI¶
Quick API calls from the terminal with the same config.
Quick Examples¶
# Simple GET (implicit call - no "call" subcommand needed)
kstlib rapi github.user
# With path parameters
kstlib rapi github.repos-issues owner=KaminoU repo=igcv3
# Query parameters (override YAML defaults)
kstlib rapi github.repos-list per_page=50 page=2
# POST with JSON body
kstlib rapi myapi.create -b '{"name": "test"}'
# Body from file (curl-like syntax)
kstlib rapi myapi.create -b @payload.json
# Multipart file upload (auto-detected from Content-Type)
kstlib rapi transfer.packages-upload -b @package.json
# Raw output (no Rich, pipeable to jq)
kstlib rapi github.user --raw | jq '.login'
# Minified JSON (compact single-line)
kstlib rapi github.user --minify --out user.json
# Output to file (for scripting)
kstlib rapi github.user -o user.json
# Full response with metadata
kstlib rapi github.user -f full -o result.json
# Custom headers
kstlib rapi github.user -H "X-Debug: true"
# List available endpoints
kstlib rapi list
kstlib rapi list github --verbose
# TRACE mode for debugging
kstlib -vvv rapi github.user
Commands¶
Command |
Description |
|---|---|
|
Make API call (implicit) |
|
List configured endpoints |
Call Options¶
Option |
Short |
Description |
|---|---|---|
|
|
Output format: |
|
|
Write output to file |
|
|
JSON body or |
|
|
Custom header (repeatable) |
|
|
Suppress status messages |
|
Output raw JSON without Rich formatting (pipeable) |
|
|
Output compact single-line JSON |
Verbosity Levels¶
kstlib rapi github.user # Normal output
kstlib -v rapi github.user # Debug logging
kstlib -vv rapi github.user # Verbose debug
kstlib -vvv rapi github.user # TRACE: body, params, elapsed time
Exit Codes¶
Code |
Meaning |
|---|---|
0 |
Success (HTTP 2xx/3xx) |
1 |
Error (HTTP 4xx/5xx, network error, invalid config) |
Configuration¶
In kstlib.conf.yml¶
rapi:
apis:
github:
base_url: "https://api.github.com"
credentials: github_token
auth_type: bearer
headers:
Accept: "application/vnd.github+json"
User-Agent: "my-app/1.0"
endpoints:
user:
path: "/user"
repos-list:
path: "/user/repos"
query: # Default query parameters
sort: updated
per_page: "10"
repos-issues:
path: "/repos/{owner}/{repo}/issues"
method: GET
External *.rapi.yml Files¶
For larger projects, define APIs in separate files:
# github.rapi.yml
name: github
base_url: "https://api.github.com"
credentials:
type: sops
config: github_token
headers:
Accept: "application/vnd.github+json"
User-Agent: "my-app/1.0"
endpoints:
user:
path: "/user"
repos-list:
path: "/user/repos"
query:
sort: updated
per_page: "10"
repos-issues:
path: "/repos/{owner}/{repo}/issues"
Load via include patterns:
# kstlib.conf.yml
rapi:
include:
- "*.rapi.yml"
- "apis/*.rapi.yml"
Or load directly:
client = RapiClient.from_file("github.rapi.yml")
client = RapiClient.discover() # Auto-discover *.rapi.yml in cwd
Defaults and Server Profiles¶
When using include: to load external files, declare shared configuration once
in rapi.defaults. All included files inherit these values:
rapi:
defaults:
base_url: https://${VIYA_HOST}
credentials:
type: file
path: ~/.sas/credentials.json
token_path: ".Default['access-token']"
auth: bearer
headers:
Accept: application/json
Content-Type: application/json
include:
- "./*/root.rapi.yml"
Multi-server patterns¶
Two distinct patterns exist for multi-server workflows. They solve different problems, so picking the right one matters.
Pattern A - ${VAR} env vars (same API, different environments)¶
Use when: same API schema, different environments (e.g. SAS Viya source vs target migration, dev vs staging vs prod).
How: declare a single defaults block with ${VAR} placeholders.
The substitution is applied globally at YAML load time, so
token_path natively supports dynamic profile selection.
rapi:
defaults:
base_url: https://${VIYA_HOST}
credentials:
type: file
path: ~/.sas/credentials.json
token_path: ".${ACTIVE_VIYA:-Default}['access-token']"
auth: bearer
# Switch environment by changing two env vars
export VIYA_HOST=viya-source.example.com ACTIVE_VIYA=source
kstlib rapi transfer.export-jobs-create
export VIYA_HOST=viya-target.example.com ACTIVE_VIYA=target
kstlib rapi transfer.packages-upload
The ~/.sas/credentials.json file holds named profiles (managed by
sas-admin auth login):
{
"Default": {"access-token": "...", "refresh-token": "..."},
"source": {"access-token": "...", "refresh-token": "..."},
"target": {"access-token": "...", "refresh-token": "..."}
}
${ACTIVE_VIYA:-Default} selects which profile is read. Each kstlib
invocation re-loads the YAML and re-expands the env vars, so switching
environment is just a matter of exporting two variables before each
command.
Caveat: in a long-running process, the env vars are baked in at
load_rapi_config() time. Changing them mid-process has no effect.
For CLI use this is a non-issue.
Pattern B - rapi.servers profiles (heterogeneous APIs)¶
Use when: a single project talks to multiple unrelated APIs with different stacks and auth (e.g. github + jira + slack).
How: declare named profiles in rapi.servers. Each profile
overrides base_url, credentials, auth, and/or headers. Defaults
are inherited via deep merge (profile values win).
rapi:
defaults:
auth: bearer
headers:
Accept: application/json
servers:
github:
base_url: https://api.github.com
credentials:
type: env
var: GITHUB_TOKEN
headers:
Accept: application/vnd.github+json
X-GitHub-Api-Version: "2022-11-28"
jira:
base_url: https://mycompany.atlassian.net
auth: api_key
credentials:
type: env
var: JIRA_API_KEY
Resolve in Python:
from kstlib.rapi import load_rapi_config
manager = load_rapi_config()
github = manager.resolve_server("github")
# github.base_url -> "https://api.github.com"
# github.auth -> "bearer" (inherited from defaults)
# github.headers -> {"Accept": "application/vnd.github+json", ...}
jira = manager.resolve_server("jira")
# jira.base_url -> "https://mycompany.atlassian.net"
# jira.auth -> "api_key" (overrides defaults)
default = manager.resolve_server(None)
# Returns defaults wrapped as ServerConfig(name="defaults", ...)
manager.server_names # ["github", "jira"]
Merge rules: server values override defaults. For headers and
credentials, the merge is recursive (add or replace individual keys).
Validation: server names must match [a-zA-Z][a-zA-Z0-9_-]*
(max 64 chars). Only base_url, credentials, auth, and headers
keys are allowed. URL schemes are restricted to http and https.
Max 20 server profiles.
Decision matrix¶
Question |
Pattern |
|---|---|
Same API schema, different hostnames? |
A (env vars) |
Different APIs (github + jira + …)? |
B ( |
Need to switch env in shell scripts? |
A |
Need typed profiles inside Python code? |
B |
SAS Viya source/target migration? |
Always A |
Anti-pattern: declaring SAS Viya source/target as
rapi.servers.source and rapi.servers.target. This duplicates
credential files and headers for nothing - use Pattern A instead.
The server: directive in *.rapi.yml files¶
Pattern B (rapi.servers) supports two YAML directives that pin a
specific endpoint or an entire API file to a named server profile. This
is useful when a project bundles many *.rapi.yml files and you want
each one to default to a specific backend without forcing the caller
to pass --server every time.
File-level (server: at the top of a *.rapi.yml file): all
endpoints in the file inherit this server unless they declare their own:
# transfer/import-jobs.rapi.yml
name: transfer-import-jobs
server: target # File-level: default for every endpoint below
endpoints:
list:
path: /transfer/packages
method: GET
# No server: directive here -> uses file-level "target"
Endpoint-level (server: inside an endpoint definition): overrides
the file-level value for this specific endpoint:
endpoints:
upload:
path: /transfer/packages
method: POST
server: target # Endpoint-level: explicit override
Cascade priority (highest to lowest):
Runtime override (CLI
--serverflag orcall(server=...)kwarg)Endpoint-level
server:in the YAML fileFile-level
server:at the top of the YAML fileNone - uses the static API config (no server resolution)
Validation at load time:
If
rapi.serversis configured and the directive references a known profile: validated and accepted.If
rapi.serversis configured but the directive references an unknown profile: raisesServerNotFoundErrorimmediately (fail fast at load, not at first request).If
rapi.serverssection is absent: logs a warning per reference (not fatal - the user may add the section later).
Programmatic usage with the server= kwarg:
from kstlib.rapi import RapiClient
client = RapiClient()
# Use the github profile (overrides any server: directive in YAML)
response = client.call("github.repos-list", server="github")
# Async mirror
response = await client.call_async("github.user", server="github")
# Module-level convenience also accepts server=
from kstlib.rapi import call
response = call("github.user", server="github")
When a server is in effect, the request build pipeline applies the profile in three places:
base_url is replaced by
server.base_urlcredentials are resolved from the inline
server.credentialsdict viaCredentialResolver.resolve_inline(no name lookup, no caching - each call re-resolves)headers are layered between API and endpoint headers in the cascade
api < server < endpoint < runtimeauth_type falls back to
server.authwhen present, otherwise toapi_config.auth_type, otherwise to"bearer"
If the resolved server name does not exist in rapi.servers, a
ServerNotFoundError is raised before any HTTP call is made.
Passing server=None (the default) preserves the legacy behavior:
the static ApiConfig is used unchanged.
Hard Limits¶
Parameter |
Default |
Hard Min |
Hard Max |
|---|---|---|---|
|
30.0 |
1.0 |
300.0 |
|
10MB |
- |
100MB |
|
3 |
0 |
10 |
|
1.0 |
0.1 |
60.0 |
|
2.0 |
1.0 |
5.0 |
|
100MB |
- |
100MB |
Key Features¶
Config-Driven: Define APIs in YAML, call by name
CLI and Python: Same config for both interfaces
Query Override: YAML defaults can be overridden at runtime
External Files:
*.rapi.ymlfor modular API definitionsAuto-Discovery:
RapiClient.discover()finds local configsMulti-Source Credentials: SOPS, environment, files, keyring
Multipart Upload: Auto-detected from Content-Type, uses httpx native
files=Output Modes:
--raw(pipeable),--minify(compact),--quiet(no Rich)File Output:
-o file.jsonfor scriptingTRACE Logging:
-vvvfor detailed debugging
Path and Query Parameters¶
Path Parameters¶
Use {param} placeholders:
endpoints:
repos-issues:
path: "/repos/{owner}/{repo}/issues"
# Python
response = client.call("github.repos-issues", owner="KaminoU", repo="igcv3")
# CLI
kstlib rapi github.repos-issues owner=KaminoU repo=igcv3
Query Parameters¶
YAML Defaults¶
endpoints:
repos-list:
path: "/user/repos"
query:
sort: updated
per_page: "10"
Runtime Override¶
Arguments override YAML defaults:
# Uses defaults: sort=updated, per_page=10
response = client.call("github.repos-list")
# Override per_page, keep sort=updated
response = client.call("github.repos-list", per_page="50")
# Override both
response = client.call("github.repos-list", sort="created", per_page="100")
# CLI: same behavior
kstlib rapi github.repos-list # defaults
kstlib rapi github.repos-list per_page=50 # override one
kstlib rapi github.repos-list sort=created per_page=100 # override both
Pagination¶
kstlib rapi github.repos-list page=1 -o page1.json
kstlib rapi github.repos-list page=2 -o page2.json
kstlib rapi github.repos-list page=3 -o page3.json
Request Body¶
# Python
response = client.call("myapi.create", body={"name": "test", "value": 42})
# CLI: inline JSON
kstlib rapi myapi.create -b '{"name": "test", "value": 42}'
# CLI: from file (curl-like)
kstlib rapi myapi.create -b @data.json
Multipart File Upload¶
For endpoints that expect multipart/form-data (file uploads), set the Content-Type header:
endpoints:
packages-upload:
path: /transfer/packages
method: POST
headers:
Content-Type: multipart/form-data
Accept: application/vnd.sas.summary+json
kstlib auto-detects multipart mode from the Content-Type header and handles boundary generation via httpx:
# Upload a file (auto-detected as multipart from endpoint config)
kstlib rapi transfer.packages-upload -b @package.json
# Python: pass @filepath string
response = client.call("transfer.packages-upload", body="@package.json")
# Python: pass FilePayload for in-memory data
from kstlib.rapi import FilePayload
payload = FilePayload(filename="data.csv", data=b"a,b\n1,2", content_type="text/csv")
response = client.call("transfer.packages-upload", body=payload)
Optional fine-tuning via multipart: section:
endpoints:
upload:
path: /upload
method: POST
headers:
Content-Type: multipart/form-data
multipart:
field_name: dataFile # default: "file"
content_type: application/zip # default: auto-detected from extension
Common Patterns¶
Error Handling¶
from kstlib.rapi import RapiClient, EndpointNotFoundError, RequestError
client = RapiClient()
# Pattern 1: Check response.ok
response = client.call("github.repos-get", owner="foo", repo="bar")
if response.ok:
print("Success:", response.data)
else:
print(f"Error: {response.status_code}")
# Pattern 2: Exception handling
try:
response = client.call("myapi.slow-endpoint")
except RequestError as e:
print(f"Request failed: {e}")
print(f"Retryable: {e.retryable}")
Response Inspection¶
response = client.call("github.user")
response.ok # True if 2xx status
response.status_code # HTTP status code
response.data # Parsed JSON (dict or list)
response.text # Raw response text
response.headers # Response headers dict
response.elapsed # Request duration in seconds
response.endpoint_ref # "github.user"
Custom Headers¶
response = client.call(
"myapi.endpoint",
headers={"X-Request-ID": "abc-123", "X-Debug": "true"}
)
Async Requests¶
import asyncio
from kstlib.rapi import RapiClient
async def main():
client = RapiClient()
# Single async call
response = await client.call_async("github.user")
# Concurrent requests
results = await asyncio.gather(
client.call_async("github.repos-list", page="1"),
client.call_async("github.repos-list", page="2"),
client.call_async("github.repos-list", page="3"),
)
asyncio.run(main())
Credentials¶
Configuration¶
# kstlib.conf.yml
credentials:
github_token:
type: sops
path: "secrets/github.sops.json"
key: "access_token"
api_key:
type: env
var: "MY_API_KEY"
rapi:
apis:
github:
credentials: github_token # Reference by name
auth_type: bearer
Credential Types¶
Type |
Keys |
Description |
|---|---|---|
|
|
Environment variable |
|
|
File with jq-like extraction |
|
|
SOPS-encrypted file |
|
|
System keyring |
Authentication Types¶
Auth Type |
Header Format |
|---|---|
|
|
|
|
|
|
Troubleshooting¶
Endpoint not found¶
Use full reference api.endpoint:
# Instead of:
kstlib rapi user # May be ambiguous
# Use:
kstlib rapi github.user
Credential resolution failed¶
Check credential source:
# For env credentials
echo $GITHUB_TOKEN
# For SOPS files
sops -d secrets/github.sops.json
TRACE debugging¶
kstlib -vvv rapi github.user
Shows: URL, headers, body sent, body received, elapsed time.
API Reference¶
Full autodoc: REST API Client
Class |
Description |
|---|---|
|
Main client for making API calls |
|
Response object with data, status, elapsed time |
|
Manages API and endpoint configuration |
|
Resolved server profile (from |
Function |
Description |
|---|---|
|
Convenience function for single sync call |
|
Convenience function for single async call |
Exception |
Description |
|---|---|
|
Base exception for rapi module |
|
Credential resolution failed |
|
Endpoint not in config |
|
Short reference matches multiple endpoints |
|
HTTP request failed |
|
Response exceeds max_response_size |
|
Named server profile not in config |