adms

command module
v0.0.0-...-0f2857b Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: May 26, 2026 License: MIT Imports: 5 Imported by: 0

README

adms

Pronounced "adams".

PostgREST-style HTTP API for PostgreSQL and MySQL, plus an optional bundled admin UI — all in one binary.

CI Go Report Card Codecov GitHub Sponsors

Status: the read / write API and the bundled admin UI both ship — see the Roadmap. Remaining items are UI polish (CSV / JSON export, keyboard shortcuts, ER diagram, full a11y pass).

TL;DR

Point adms at a database and you get two ways in: an HTTP API the frontend can call directly, and an optional browser-based admin UI hosted from the same binary. No service layer, no codegen, no schema duplicated in two places.

As an HTTP API
# adms.yaml
driver: postgres
dsn: "${ADMS_DSN}"
ADMS_DSN="postgres://postgres@localhost/myapp?sslmode=disable" adms
# list active users, newest first, with their three latest posts embedded
curl 'http://localhost:7777/users?status=eq.active&order=created_at.desc&limit=10&select=id,name,posts(id,title)'
[
  {
    "id": 42,
    "name": "alice",
    "posts": [
      {
        "id": 1001,
        "title": "Hello"
      },
      {
        "id": 998,
        "title": "Notes on B-trees"
      }
    ]
  }
]
As a browser UI
# adms.yaml
driver: postgres
dsn: "${ADMS_DSN}"
ui:
  enabled: true
ADMS_DSN="postgres://postgres@localhost/myapp?sslmode=disable" adms
# → open http://localhost:7778/

You land on a dark-mode admin console with schema-grouped tables in the sidebar, sortable / filterable / pageable row views in the main pane, FK-aware embedded rows, inline editing, typed insert forms, and a built-in schema viewer. No node_modules, no separate deploy — the UI is embedded in the binary.

The same idea drives both surfaces: reads, writes, joins, ordering, paging, counting — all defined by your database schema.

Why

Two friction points in every admin tool you have ever built:

  1. The backend is generic, but you keep writing it. List endpoints with filters, sorting, paging, related rows, CRUD — the shape is already in the database schema, but every project hand-writes it again.
  2. The frontend is generic too, eventually. Once the API exists, the dashboard becomes "tables with filters and forms" yet again. Spinning up a React app, picking a component library, and wiring it up is a separate, parallel project.

adms collapses both into a single binary. It introspects your database on startup and exposes a PostgREST-style HTTP API automatically. Set ui.enabled: true in the config and the same binary serves a complete admin frontend — no extra deploy, no separate codebase.

The closest neighbor is PostgREST itself: excellent, but PostgreSQL only and API only. adms aims for PostgreSQL + MySQL and API + (optional) UI, with no extra dependencies to install beyond the binary.

Install

Tagged releases are not out yet. Once v0.1.0 ships:

# Homebrew (tap)
brew install mickamy/tap/adms

# go install
go install github.com/mickamy/adms@latest

While unreleased, build from source:

git clone https://github.com/mickamy/adms
cd adms
make build
./bin/adms --version

Quickstart

adms reads a YAML or TOML config file. With no positional argument it auto-detects adms.yaml, adms.yml, then adms.toml in the current directory; otherwise pass the path explicitly (e.g., adms /etc/adms.yaml). Strings in the config are expanded from the environment via ${VAR} / $VAR, so secrets stay out of the file.

PostgreSQL
# adms.yaml
driver: postgres
dsn: "${ADMS_DSN}"
ADMS_DSN="postgres://postgres@localhost:5432/myapp?sslmode=disable" adms
MySQL
# adms.yaml
driver: mysql
dsn: "${ADMS_DSN}"
ADMS_DSN="user:pass@tcp(localhost:3306)/myapp?parseTime=true" adms

On boot, adms introspects the target database, builds an in-memory schema model, and starts listening on :7777 (override with listen: in the config). Every introspected table becomes a resource at /<table_name>. With ui.enabled: true, a second listener on :7778 (override with ui.listen:) also serves the bundled admin UI from the same process.

Today (Phase 1.5): adms connects, introspects, and prints a per-schema summary, then exits. The HTTP server, health endpoint, and table resources described below land in Phase 2.

Verify it works (Phase 2+):

curl http://localhost:7777/                  # schema dump (JSON)
curl http://localhost:7777/healthz           # → "ok"
curl http://localhost:7777/<some_table>      # first 100 rows as JSON

The HTTP API

GET /<table> returns rows. Everything else — filtering, projection, ordering, paging, embedding — is driven by URL query parameters. Writes use POST / PATCH / DELETE with JSON bodies. The shape mirrors PostgREST so existing clients and mental models transfer.

Reading data
Filters

A filter has the form ?<column>=<op>.<value>. Multiple filters are AND-combined.

Operator Example SQL equivalent
eq status=eq.active status = 'active'
gt / gte age=gte.18 age >= 18
ilike name=ilike.AL* name ILIKE 'AL%' (MySQL: case-insensitive via LOWER())
in id=in.(1,2,3) id IN (1, 2, 3)
is deleted_at=is.null deleted_at IS NULL
like name=like.al* name LIKE 'al%'
lt / lte score=lt.100 score < 100
neq status=neq.banned status <> 'banned'
not status=not.eq.banned NOT (status = 'banned')

Wildcards in like / ilike use * (translated to %); _ remains a single-character wildcard.

curl 'http://localhost:7777/users?status=eq.active&age=gte.18&deleted_at=is.null'
Projection (select)

By default, every column is returned. Use select to pick columns:

curl 'http://localhost:7777/users?select=id,name,email'

Use * to mean "all columns of this row":

curl 'http://localhost:7777/users?select=*,created_at'

adms reads foreign keys from the schema and lets you embed related rows by table name in parentheses:

# user → posts (one-to-many via posts.user_id → users.id)
curl 'http://localhost:7777/users?id=eq.1&select=id,name,posts(id,title,created_at)'
# post → author (many-to-one), with an alias
curl 'http://localhost:7777/posts?select=*,author:users(id,name)'

Embeds nest:

curl 'http://localhost:7777/users?select=*,posts(id,title,comments(id,body))'

Embedded relations resolve to JSON arrays for one-to-many, and JSON objects for many-to-one, derived from the FK direction.

Ordering and paging
curl 'http://localhost:7777/users?order=created_at.desc,id.asc&limit=20&offset=40'

limit defaults to 100 when omitted and is capped at 1000.

Counting rows

To get a total count alongside the page, send Prefer: count=exact:

curl -i -H 'Prefer: count=exact' 'http://localhost:7777/users?limit=20'
HTTP/1.1 200 OK
Content-Range: 0-19/1342
Content-Type: application/json
Writing data

All write methods accept JSON bodies (Content-Type: application/json assumed).

Insert
curl -X POST http://localhost:7777/users \
  -H 'Content-Type: application/json' \
  -d '{"name": "carol", "status": "active"}'
HTTP/1.1 201 Created
Location: /users?id=eq.42
Bulk insert
curl -X POST http://localhost:7777/users \
  -H 'Content-Type: application/json' \
  -d '[{"name": "dave"}, {"name": "eve"}]'
Update

PATCH requires at least one filter — adms rejects an unfiltered PATCH with 400 Bad Request to prevent accidental table-wide updates.

curl -X PATCH 'http://localhost:7777/users?id=eq.1' \
  -H 'Content-Type: application/json' \
  -d '{"status": "inactive"}'
Delete

Same rule as PATCH: a filter is mandatory.

curl -X DELETE 'http://localhost:7777/users?id=eq.1'
Prefer header
Value Effect
count=exact Content-Range header with total row count
return=minimal (default for writes) Empty body, Location header for inserts
return=representation Body contains the affected rows
curl -X POST http://localhost:7777/users \
  -H 'Content-Type: application/json' \
  -H 'Prefer: return=representation' \
  -d '{"name": "frank"}'
{
  "id": 43,
  "name": "frank",
  "status": null,
  "created_at": "2026-05-21T08:12:00Z"
}
Errors

Errors follow a PostgREST-shaped JSON envelope with adms-specific codes (prefixed ADMS_):

{
  "code": "ADMS_UNKNOWN_COLUMN",
  "message": "column \"foo\" does not exist in table \"users\"",
  "details": null,
  "hint": "available columns: id, name, status, created_at"
}
HTTP code When
400 ADMS_INVALID_FILTER Bad operator or value format
400 ADMS_UNFILTERED_WRITE PATCH / DELETE without any filter
400 ADMS_UNKNOWN_COLUMN Column name not in schema
403 ADMS_READ_ONLY Write attempted while read_only: true
404 ADMS_UNKNOWN_TABLE Table name not in (allowed) schema
409 ADMS_CONFLICT DB-level unique / FK violation
422 ADMS_INVALID_BODY JSON body fails column-type validation
500 ADMS_INTERNAL Anything unexpected
Schema endpoint

GET / returns the introspected schema as JSON. A frontend (yours or the bundled admin UI) uses this to render forms, infer column types, and discover relations without bundling a schema of its own.

curl http://localhost:7777/
{
  "tables": [
    {
      "schema": "public",
      "name": "users",
      "primary_key": [
        "id"
      ],
      "columns": [
        {
          "name": "id",
          "type": "bigint",
          "nullable": false,
          "default": "nextval(...)"
        },
        {
          "name": "name",
          "type": "text",
          "nullable": false
        },
        {
          "name": "status",
          "type": "text",
          "nullable": true
        },
        {
          "name": "created_at",
          "type": "timestamptz",
          "nullable": false,
          "default": "now()"
        }
      ],
      "foreign_keys": [],
      "referenced_by": [
        {
          "table": "posts",
          "columns": [
            "user_id"
          ],
          "references": [
            "id"
          ]
        }
      ]
    }
  ]
}

The admin UI

Enabled with ui.enabled: true in the config. Off by default, so API-only deployments stay lean. When on, the UI is served on a separate listener (ui.listen, default :7778) by the same process. The API at :7777 stays untouched, with table names occupying the full URL root. The UI calls the same HTTP API documented above, with CORS auto-configured between the two listeners — it is not a parallel implementation, it is the first-class client of it.

The UI is a single-binary affair: HTML, CSS, and JavaScript are embedded into the adms executable via embed.FS — including the tree-shaken minified Tailwind CSS bundle, so deployments in closed networks need no external CDN access. No node_modules, no separate frontend deploy. It is rendered server-side with Go's html/template and made interactive with vanilla fetch against the same HTTP API documented above.

What you get
  • Sidebar — schema-grouped table list with incremental search.
  • Table view — row list with PostgREST-style filter inputs (kind-aware placeholders, bare values auto-prefixed with the kind-default operator), column-header sort, paging, FK arrows that jump to the referenced row.
  • Row detail — type-aware inputs by column kind (<select> for booleans, type="number" / type="date", <textarea> for JSON), outgoing FK link that live-updates as you edit, and a "Referenced by" section listing incoming relationships as filtered table views.
  • Edit — double-click any cell for in-place editing, or open the row in a modal via the row's "edit" button. Both paths submit PATCH /:table?<pk>=eq.<id> and refresh the visible rows on success.
  • Insert — kind-aware form on /t/{table}/new. Empty inputs are omitted from the POST so column defaults / NULL apply.
  • Delete — confirm dialog → DELETE /:table?<pk>=eq.<id>.
  • Schema viewer — at /t/{table}/schema: columns (name / type / nullable / default / generated-or-identity / comment), primary key, outgoing FKs, incoming FKs (Referenced by), and indexes (name, columns, UNIQUE, method, partial-index predicate). FK / Referenced-by entries link to the other table's schema page.
Design
  • Dark mode by default.
  • Responsive down to tablet widths (>= 768px).
  • Read-only gating — when read_only: true the UI hides every write affordance (+ New, edit / delete buttons, inline-edit, modal, Save / Delete on row detail) and /t/{table}/new returns 404.
  • Type-aware forms — boolean / integer / number / date / JSON / text inputs and the JS value parser dispatch on the same Go-side inputKind classifier so client and server agree on the column shape.
Access

The UI calls the same HTTP API you would. Cross-origin calls between the two listeners are handled automatically — adms adds the UI's origin to the API's allowed origins, so you do not need to list it in cors_origins. When auth_token_env names a populated env var, the UI carries that token on every request. When read_only: true, the UI hides edit / insert / delete affordances. The UI does not introduce its own login flow — keep it behind your network or gateway.

Security

adms is designed to sit behind your authn layer (reverse proxy, API gateway, etc.), but it ships several built-in safety nets so an accidental misconfiguration is not catastrophic.

Identifier allowlist

Table and column names from query parameters are checked against the introspected schema before they are interpolated into SQL. Unknown identifiers return 400 Bad Request, never reach the database, and never appear in error messages echoed back to the client unsanitized.

Read-only mode
read_only: true

Returns 403 Forbidden for POST, PATCH, and DELETE. The admin UI hides write affordances in this mode. Useful for staging dashboards, demos, or anywhere writes must be impossible by construction.

Schema and table allowlist

Restrict which schemas (or tables) are exposed:

allowed_schemas: [public, reporting]
allowed_tables: [users, posts, comments]

Anything outside the allowlist is invisible — at GET /, at the per-table endpoints, and in the UI sidebar.

Bearer token
auth_token_env: ADMS_TOKEN

When set, adms reads the bearer token from the named environment variable and requires every request to include Authorization: Bearer <token>. The admin UI carries the token automatically (the resolved value is exposed via a meta tag that an inline fetch wrapper picks up and attaches to every API-origin request). This is intentionally simple — for OIDC / JWT, terminate auth at your gateway. The token value itself never appears in the config file, so it does not leak into version control.

CORS
cors_origins:
  - "https://admin.example.com"
  - "https://staff.example.com"

Defaults to no CORS headers, so the API is only reachable from same-origin contexts unless you opt in. When ui.enabled: true, the bundled admin UI's origin is automatically added to the allowed origins — you do not need to list it here.

Mandatory filters on writes

PATCH and DELETE without a where clause return 400 — there is no "update every row" path, in the API or the UI.

CLI

adms [config-file]

If the argument is omitted, adms looks for adms.yaml, adms.yml, then adms.toml
in the current directory. Pass a path to use a specific config file.

Flags:
  --version, -v   Print version
  --help, -h      Show help

The config file is the single source of configuration — there are no per-setting CLI flags or ADMS_* reserved environment variables. Strings in the config file are expanded via ${VAR} / $VAR from the environment so secrets (DSN, bearer token, etc.) stay out of source control; the env var names you reference (ADMS_DSN, DATABASE_URL, anything you like) are entirely your choice. Unset variables expand to "", and literal $ cannot be escaped, so put values containing $ in an environment variable.

Configuration (config file)

A minimal config:

driver: postgres
dsn: "${ADMS_DSN}"

The full set of fields, with defaults and meaning:

Field Default Description
driver (required) postgres or mysql
dsn (required) Database connection string (prefer ${VAR} expansion)
listen :7777 API listen address
read_only false Reject all write methods with 403
allowed_schemas (driver default) Schemas to introspect
allowed_tables (all) Table allowlist (empty means every introspected table)
timeout 30s Startup operation timeout (DSN parsing, introspect, etc.)
cors_origins (none) Allowed origins for CORS
auth_token_env (none) Name of the env var holding a bearer token to require
log_level info debug / info / warn / error
ui.enabled false Mount the bundled admin UI
ui.listen :7778 Listen address for the admin UI

TOML works the same way:

driver = "postgres"
dsn = "${ADMS_DSN}"
listen = ":7777"
read_only = false
allowed_schemas = ["public"]

[ui]
enabled = false
listen = ":7778"

Working examples live in examples/adms.yaml and examples/adms.toml.

Roadmap

  • Phase 0 — CLI scaffolding, goreleaser metadata
  • Phase 1 — Schema introspection (PostgreSQL + MySQL, including indexes with method and partial predicate)
  • Phase 1.5 — Config-file driven CLI; subcommands and per-setting flags removed
  • Phase 2 — HTTP server, GET / schema endpoint, GET /healthz, graceful shutdown
  • Phase 3 — Read API: filter (incl. cs / cd JSON / array containment), projection, ordering, paging
  • Phase 4 — Read API: relation embedding (FK-aware JSON aggregation)
  • Phase 5 — Write API: POST / PATCH / DELETE, Prefer header, Content-Range
  • Phase 6 — CORS, structured logging, panic recovery, read_only, allowlists, bearer token
  • Phase 7 — Bundled admin UI (opt-in via ui.enabled): separate listener (ui.listen, default :7778), HTML/CSS/JS embedded via embed.FS, SSR with html/template + minified Tailwind, CORS auto-configured, dark mode, type-aware form inputs, inline cell + modal edit, FK navigation, schema viewer, bearer-token forwarding, read-only gating
  • CSV / JSON export of filtered query results
  • Keyboard shortcuts (Cmd/Ctrl+K table palette, ↑↓ row navigation)
  • a11y polish (skeleton loaders, explicit empty / error states, light-mode toggle)
  • Schema viewer ER diagram

Why not PostgREST?

Use PostgREST if you are PostgreSQL-only and want a battle-tested project with a large community — it is genuinely excellent, and adms borrows heavily from its URL conventions.

Reach for adms when:

  • you are on MySQL, or operating a fleet with both PostgreSQL and MySQL, and want one server to manage,
  • you want a UI shipped in the same binary as the API, not a separate frontend project,
  • you prefer a single self-contained Go binary with no Haskell runtime to deploy,
  • you want a tighter scope focused on admin dashboards — opinionated defaults, identifier allowlists, mandatory filters on writes — rather than a general-purpose data API.

Acknowledgements

The URL conventions, the embedding syntax, and the Prefer-header semantics in this project are taken almost verbatim from PostgREST. The admin UI is styled with Tailwind CSS, generated by the standalone tailwindcss CLI and bundled into the binary at build time. Standing on giants' shoulders — thank you.

Sponsor

If adms saves you time, consider supporting ongoing development via GitHub Sponsors. Sponsorships pay for the maintenance time that keeps Postgres / MySQL parity, security fixes, and roadmap items moving.

License

MIT © 2026 Tetsuro Mikami

Documentation

The Go Gopher

There is no documentation for this package.

Directories

Path Synopsis
internal
cli
logger
Package logger wraps log/slog so other packages can log without importing slog directly.
Package logger wraps log/slog so other packages can log without importing slog directly.
ui
Package ui serves the optional admin web UI on its own listener so the API URL space can stay 100% owned by table names.
Package ui serves the optional admin web UI on its own listener so the API URL space can stay 100% owned by table names.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL