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

kstlib rapi <api>.<endpoint> [args...]

Make API call (implicit)

kstlib rapi list [API]

List configured endpoints

Call Options

Option

Short

Description

--format

-f

Output format: json (default), text, full

--out

-o

Write output to file

--body

-b

JSON body or @filename to read from file

--header

-H

Custom header (repeatable)

--quiet

-q

Suppress status messages

--raw

Output raw JSON without Rich formatting (pipeable)

--minify

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 (rapi.servers)

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):

  1. Runtime override (CLI --server flag or call(server=...) kwarg)

  2. Endpoint-level server: in the YAML file

  3. File-level server: at the top of the YAML file

  4. None - uses the static API config (no server resolution)

Validation at load time:

  • If rapi.servers is configured and the directive references a known profile: validated and accepted.

  • If rapi.servers is configured but the directive references an unknown profile: raises ServerNotFoundError immediately (fail fast at load, not at first request).

  • If rapi.servers section 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_url

  • credentials are resolved from the inline server.credentials dict via CredentialResolver.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 < runtime

  • auth_type falls back to server.auth when present, otherwise to api_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

timeout

30.0

1.0

300.0

max_response_size

10MB

-

100MB

max_retries

3

0

10

retry_delay

1.0

0.1

60.0

retry_backoff

2.0

1.0

5.0

max_upload_size

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.yml for modular API definitions

  • Auto-Discovery: RapiClient.discover() finds local configs

  • Multi-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.json for scripting

  • TRACE Logging: -vvv for 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

env

var

Environment variable

file

path, token_path

File with jq-like extraction

sops

path, key

SOPS-encrypted file

keyring

service, username

System keyring

Authentication Types

Auth Type

Header Format

bearer

Authorization: Bearer <token>

basic

Authorization: Basic <base64(user:pass)>

api_key

X-API-Key: <key>

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

RapiClient

Main client for making API calls

RapiResponse

Response object with data, status, elapsed time

RapiConfigManager

Manages API and endpoint configuration

ServerConfig

Resolved server profile (from resolve_server())

Function

Description

call

Convenience function for single sync call

call_async

Convenience function for single async call

Exception

Description

RapiError

Base exception for rapi module

CredentialError

Credential resolution failed

EndpointNotFoundError

Endpoint not in config

EndpointAmbiguousError

Short reference matches multiple endpoints

RequestError

HTTP request failed

ResponseTooLargeError

Response exceeds max_response_size

ServerNotFoundError

Named server profile not in config