https://github.com/mickamy/adms
PostgREST-style HTTP API for PostgreSQL and MySQL, plus an optional bundled admin UI — all in one binary.
https://github.com/mickamy/adms
admin crud golang http-api mysql postgresql postgrest rest-api schema
Last synced: 9 days ago
JSON representation
PostgREST-style HTTP API for PostgreSQL and MySQL, plus an optional bundled admin UI — all in one binary.
- Host: GitHub
- URL: https://github.com/mickamy/adms
- Owner: mickamy
- License: mit
- Created: 2026-05-21T09:02:35.000Z (about 2 months ago)
- Default Branch: main
- Last Pushed: 2026-05-21T12:01:14.000Z (about 2 months ago)
- Last Synced: 2026-05-21T19:57:24.912Z (about 2 months ago)
- Topics: admin, crud, golang, http-api, mysql, postgresql, postgrest, rest-api, schema
- Language: Go
- Size: 47.9 KB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 1
-
Metadata Files:
- Readme: README.md
- Funding: .github/FUNDING.yaml
- License: LICENSE
Awesome Lists containing this project
README
# adms
_Pronounced "adams"._
PostgREST-style HTTP API for PostgreSQL and MySQL, plus an optional bundled admin UI — all in one binary.
[](https://github.com/mickamy/adms/actions/workflows/ci.yaml)
[](https://goreportcard.com/report/github.com/mickamy/adms)
[](https://codecov.io/gh/mickamy/adms)
[](LICENSE)
[](https://github.com/sponsors/mickamy)
> Status: the read / write API and the bundled admin UI both ship — see the [Roadmap](#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
```yaml
# adms.yaml
driver: postgres
dsn: "${ADMS_DSN}"
```
```sh
ADMS_DSN="postgres://postgres@localhost/myapp?sslmode=disable" adms
```
```sh
# 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)'
```
```json
[
{
"id": 42,
"name": "alice",
"posts": [
{
"id": 1001,
"title": "Hello"
},
{
"id": 998,
"title": "Notes on B-trees"
}
]
}
]
```
### As a browser UI
```yaml
# adms.yaml
driver: postgres
dsn: "${ADMS_DSN}"
ui:
enabled: true
```
```sh
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](https://postgrest.org/)-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:
```sh
# Homebrew (tap)
brew install mickamy/tap/adms
# go install
go install github.com/mickamy/adms@latest
```
While unreleased, build from source:
```sh
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
```yaml
# adms.yaml
driver: postgres
dsn: "${ADMS_DSN}"
```
```sh
ADMS_DSN="postgres://postgres@localhost:5432/myapp?sslmode=disable" adms
```
### MySQL
```yaml
# adms.yaml
driver: mysql
dsn: "${ADMS_DSN}"
```
```sh
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 `/`. 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+):
```sh
curl http://localhost:7777/ # schema dump (JSON)
curl http://localhost:7777/healthz # → "ok"
curl http://localhost:7777/ # first 100 rows as JSON
```
## The HTTP API
`GET /` 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 `?=.`. 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.
```sh
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:
```sh
curl 'http://localhost:7777/users?select=id,name,email'
```
Use `*` to mean "all columns of this row":
```sh
curl 'http://localhost:7777/users?select=*,created_at'
```
#### Embedding related rows
`adms` reads foreign keys from the schema and lets you embed related rows by table name in parentheses:
```sh
# 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)'
```
```sh
# post → author (many-to-one), with an alias
curl 'http://localhost:7777/posts?select=*,author:users(id,name)'
```
Embeds nest:
```sh
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
```sh
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`:
```sh
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
```sh
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
```sh
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.
```sh
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.
```sh
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 |
```sh
curl -X POST http://localhost:7777/users \
-H 'Content-Type: application/json' \
-H 'Prefer: return=representation' \
-d '{"name": "frank"}'
```
```json
{
"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_`):
```json
{
"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.
```sh
curl http://localhost:7777/
```
```json
{
"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 (`` for booleans, `type="number"` / `type="date"`,
`` 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?=eq.` 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?=eq.`.
- **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
```yaml
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:
```yaml
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
```yaml
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 `. 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
```yaml
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:
```yaml
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:
```toml
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`](examples/adms.yaml) and [`examples/adms.toml`](examples/adms.toml).
## Roadmap
- [x] Phase 0 — CLI scaffolding, goreleaser metadata
- [x] Phase 1 — Schema introspection (PostgreSQL + MySQL, including indexes with method and partial predicate)
- [x] Phase 1.5 — Config-file driven CLI; subcommands and per-setting flags removed
- [x] Phase 2 — HTTP server, `GET /` schema endpoint, `GET /healthz`, graceful shutdown
- [x] Phase 3 — Read API: filter (incl. `cs` / `cd` JSON / array containment), projection, ordering, paging
- [x] Phase 4 — Read API: relation embedding (FK-aware JSON aggregation)
- [x] Phase 5 — Write API: `POST` / `PATCH` / `DELETE`, `Prefer` header, `Content-Range`
- [x] Phase 6 — CORS, structured logging, panic recovery, `read_only`, allowlists, bearer token
- [x] 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](https://postgrest.org/). The admin UI is styled with [Tailwind CSS](https://tailwindcss.com/),
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](https://github.com/sponsors/mickamy). Sponsorships pay for the maintenance time that keeps
Postgres / MySQL parity, security fixes, and roadmap items moving.
## License
[MIT](LICENSE) © 2026 Tetsuro Mikami