# apic
A small CLI for **git-able API contracts**. Each endpoint is a plain JSON file
that lives in your repository, so contracts are diffable, reviewable in pull
requests, and versioned alongside the code they describe. `apic` discovers,
renders, and scaffolds those files.`
https://github.com/user-attachments/assets/89b5fb4b-7942-49a1-9bee-41308d234236
## Why
Mainstream API tools like Postman and apidoc gate collaboration behind a
paywall — you pay per team member to share workspaces, and seats add up fast as
a team grows. `apic` takes a different approach: contracts are plain JSON files
in your repository, so **your existing git workflow _is_ the collaboration
layer**. No seats, no separate accounts — if someone can clone the repo, they
can read, edit, and review contracts.
That means contracts are:
- **Free to collaborate on** — sharing is `git push`/`git pull`, not a billing
tier. Everyone with repo access already has full access.
- **Version-controlled** — contracts change in the same commit as the code, with
full history and blame.
- **Reviewable** — a contract change is a readable diff in a pull request,
reviewed by the same people on the same platform as the code.
- **Readable** — `apic read` renders a contract as a clean, colorized table in
the terminal instead of raw JSON.
## Install
**crates.io** (recommended) — installs the `apic` command:
```bash
cargo install apic-cli
```
**From source** (requires a Rust toolchain, 1.88+):
```bash
git clone https://github.com/rizukirr/apic
cd apic
cargo install --path .
```
To run without installing, use `cargo run -- <args>` from the project directory.
**Prebuilt binaries** — grab the archive for your platform from the
[latest release](https://github.com/rizukirr/apic/releases), verify the
`.sha256` checksum, extract, and put `apic` on your `PATH`. Builds are provided
for Linux (x86_64, aarch64), macOS (Intel, Apple Silicon), and Windows (x86_64).
## Quick start
```bash
# 1. Initialize a project in the current directory (creates .apic/config.toml)
apic init
# 2. Point apic at the folder that holds your contract files
apic config --set-dir api-contract
# 3. Scaffold a new contract from a template (opens it in your editor)
apic create -f auth/login.json
# 4. List and read contracts
apic list
apic read -f login
```
## Commands
### `apic init [--set-dir <dir>]`
Initializes an `.apic` project in the current directory by creating
`.apic/config.toml`. The optional `--set-dir` records which directory contract
files are scanned from (defaults to the current directory).
### `apic config [--set-dir <dir>]`
Updates project configuration.
- `--set-dir <dir>` — change the working directory that contracts are scanned
from (must exist).
### `apic create -f <filename> [-e <editor>]`
Creates a new contract from the project template (`.apic/template.json`,
falling back to the built-in default) and opens it in your editor. A relative
path is resolved against the configured working directory. `apic` refuses to
overwrite an existing file.
Editor resolution order: `--editor` flag → `$VISUAL` → `$EDITOR` → `vi`. The
`-e`/`--editor` flag picks the editor for a single invocation (e.g.
`apic create -f auth/login.json -e nano`) and the value may include arguments.
GUI editors need their wait flag (`code --wait`, `subl -w`) so `apic` waits for
the file to be saved.
### `apic list [--filter <query>] [--absolute <true|false>]`
Lists discovered `.json` contract files under the working directory.
- `--filter <query>` — show only contracts whose path fuzzy-matches the query,
best match first (e.g. `apic list --filter user`).
- `--absolute <true|false>` — print absolute paths or paths relative to the
working directory (`false`, the default).
### `apic read -f <filename> [-s <status>]`
Renders a contract as formatted tables. `-s <status>` filters the response
section to a single HTTP status code.
`<filename>` is resolved flexibly — an exact match wins, then fuzzy:
1. a path relative to the working directory — `user/user.json`
2. the same without the `.json` extension — `user/user`, `auth/login`
3. a fuzzy fragment — `user`, `logn`
```bash
apic read -f user/user.json # exact path
apic read -f auth/login # extension optional
apic read -f login # fuzzy
apic read -f login -s 401 # show only the 401 response
apic read -f login --example # show raw JSON example payloads
```
By default each schema table is followed by its example payload (when the
contract provides one), labeled `Example:`, so structure and a concrete
payload read together. With `--example` (or `-e`) the schema tables are
skipped entirely and only the raw JSON payloads print — the compact
copy-paste view:
```text
REQUEST
{
"username": "rizukirr",
"password": "123qweA@"
}
RESPONSE 200 — Successful login
{
"status": 200,
"message": "Login successful",
"data": { "access_token": "..." }
}
```
### `apic open (-f <filename> | --template) [-e <editor>]`
Resolves `<filename>` exactly like `read` (path, extensionless, or fuzzy) and
opens the matching contract in your editor — the same editor resolution as
`apic create`, including the `-e`/`--editor` flag.
Pass `--template` instead of `-f` to edit the project template
(`.apic/template.json`) that `apic create` scaffolds from; it is seeded from
the built-in default first if it does not exist yet. `--template` and `-f` are
mutually exclusive, and exactly one is required.
```bash
apic open -f user/user.json
apic open -f user
apic open -f user -e nano # open with a one-off editor
apic open --template # edit the project template
```
Output is colorized when stdout is a terminal and plain when piped, so it stays
clean in scripts. Contract strings are sanitized before display, so a file from
an untrusted source cannot inject terminal escape sequences.
### `apic remove -f <filename>`
Resolves `<filename>` exactly like `read`/`open` (path, extensionless, or
fuzzy, prompting to pick when ambiguous) and deletes the matching contract
file. On an interactive terminal it asks `Remove <path>? [y/N]` first and only
deletes on `y`/`yes`; when stdin/stdout is not a terminal (scripts) it removes
without prompting.
```bash
apic remove -f user/user.json
apic remove -f login # fuzzy, with confirmation
```
### `apic validate [-f <filename>]`
Checks that contracts parse and conform to the schema. With no `-f`, every
contract under the working directory is checked; with `-f <name>` only the best
fuzzy match is. Prints `ok`/`FAIL` per file with the parse error (line and
column) for failures, and **exits non-zero if any contract is invalid** — so it
drops straight into a CI step or pre-commit hook.
```bash
apic validate # check every contract
apic validate -f login # check one
```
```text
ok auth/login.json
FAIL user/user.json: EOF while parsing an object at line 12 column 1
2 passed, 1 failed
```
## Security
`apic` treats contract files and paths as untrusted, so it is safe to run
against contracts from any source:
- **Terminal-escape safe** — all file-derived strings (contract fields, file
names) are stripped of control characters before printing.
- **Path-confined** — `apic create` refuses paths that escape the working
directory via `..` or an absolute path elsewhere.
- **Bounded** — contract files larger than 5 MiB are rejected before reading,
and pathologically nested JSON is rejected rather than overflowing the stack.
## Contract format
A contract is a single JSON object describing one endpoint. See
[`src/templates/contract.json`](src/templates/contract.json) for the full
template that `apic create` writes.
`apic init` writes a starter template to `.apic/template.json`. Edit it to set
a project-wide convention — for example a standing `device-id` header — and
every `apic create` reuses it. The file is never overwritten once it exists; if
it is missing or malformed, `apic create` falls back to the built-in default.
```json
{
"name": "update-user",
"description": "Update a user",
"method": "PUT",
"url": {
"protocol": "https",
"host": "api.example.com",
"path": ["users", "{id}"],
"query": [
{
"name": "notify",
"value": "true",
"description": "Send a notification email",
"required": false
}
],
"variable": [
{
"name": "id",
"type": "int",
"description": "User ID"
}
]
},
"headers": [
{ "name": "Content-Type", "value": "application/json" },
{ "name": "Authorization", "value": "Bearer {token}" }
],
"request": {
"schema": [
{
"name": "name",
"type": "string",
"default": null,
"description": "Display name",
"required": true
}
],
"example": {
"name": "Rizki Rakasiwi"
}
},
"responses": [
{
"code": 200,
"description": "User updated",
"schema": [
{
"name": "status",
"type": "int",
"default": "200",
"description": "Status code",
"required": true,
"properties": null
},
{
"name": "message",
"type": "string",
"default": null,
"description": "Human-readable message",
"required": true,
"properties": null
}
],
"example": {
"status": 200,
"message": "OK"
}
}
]
}
```
Both `schema` (field-level detail, rendered as tables) and `example` (a raw
JSON payload) are optional in the request and in each response — early-stage
contracts often start with just an example, formal ones with just a schema.
The default view shows the example beneath its schema table (or alone when
there is no schema), and `read --example` shows only the payloads.
### Fields
| `name` | yes | Endpoint name. |
| `description` | no | Short description of the endpoint. |
| `method` | yes | HTTP method (`GET`, `POST`, …). |
| `url` | yes | Request URL, broken into parts (see below). |
| `headers` | yes | Array of headers (`name`, `value`). |
| `request` | no | Request body: `{ "schema": [fields], "example": <raw JSON> }` — both parts optional. |
| `responses` | yes | Array of responses (`code`, `description`, optional `schema`, optional `example`). |
The `url` object has:
| `protocol` | yes | URL scheme, e.g. `http` or `https`. |
| `host` | yes | Host, e.g. `api.example.com`. |
| `path` | no | Path segments as an array, e.g. `["auth", "login"]`. |
| `query` | no | Array of query parameters (`name`, `value`, `description`, `required`). |
| `variable` | no | Array of path variables (`name`, optional `type` — defaults to `string`, `description`). |
A **field** (in the request `schema` and response `schema`) has:
| `name` | Field name. |
| `type` | Data type (`string`, `int`, `file`, `object`, …). |
| `default` | Default value as a string, or `null`. |
| `description` | Field description. |
| `required` | Whether the field is required. |
| `accept` | Allowed MIME types for `file` fields, e.g. `"image/png, image/jpeg"`. Request only; omit for ordinary fields. |
| `properties` | Nested fields (for `object` types), or `null`. Response schema only. |
### Multipart / file uploads
For `multipart/form-data` endpoints, declare the encoding in the
`Content-Type` header as usual and use `"type": "file"` for file parts. The
optional `accept` field documents which MIME types the part allows, and
`apic read` shows it in an extra ACCEPT column:
```json
{
"name": "upload-avatar",
"method": "POST",
"url": {
"protocol": "https",
"host": "api.example.com",
"path": ["user", "avatar"]
},
"headers": [
{ "name": "Content-Type", "value": "multipart/form-data" }
],
"request": {
"schema": [
{
"name": "avatar",
"type": "file",
"default": null,
"description": "Avatar image, max 2MB",
"required": true,
"accept": "image/png, image/jpeg"
},
{
"name": "caption",
"type": "string",
"default": null,
"description": "Optional caption",
"required": false
}
]
},
"responses": []
}
```
```text
REQUEST
NAME TYPE REQ ACCEPT DESCRIPTION
avatar file ✓ image/png, image/jpeg Avatar image, max 2MB
caption string Optional caption
```
## Configuration
`apic init` writes `.apic/config.toml`:
```toml
name = "apic"
version = "0.1.0"
[root]
working_dir = "api-contract"
```
`working_dir` is stored relative to the project root, so `.apic/config.toml`
is safe to commit and share — it resolves correctly on any clone. `apic`
locates the project by walking up from the current directory to find the
`.apic` directory, so commands work from anywhere inside the project tree.
## License
Licensed under the [MIT License](LICENSE).