Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 18 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ A lightweight, self-hosted Function-as-a-Service platform written in Go with Lua
* **Execution History** - Monitor function executions and logs
* **Beautiful Error Messages** - Human-friendly error messages with code context, line numbers, and actionable suggestions
* **Web Dashboard** - Manage functions through a clean web interface
* **API Documentation** - Swagger UI available at `/docs`
* **GraphQL API** - Typed, introspectable management API with a GraphiQL playground at `/graphql`
* **Lightweight** - Single binary, no external dependencies

## Screenshots
Expand Down Expand Up @@ -265,20 +265,28 @@ The dashboard requires authentication via API key. You can:
1. **Auto-generate** (recommended) - Let Lunar generate a secure key on first run
2. **Set manually** - Provide your own key via the `API_KEY` environment variable

API calls can authenticate using either:
The management API is served over **GraphQL** at `/graphql` (with a GraphiQL
playground in the browser). Requests authenticate using either:
- **Cookie** - Automatically handled by the dashboard after login
- **Bearer token** - Include `Authorization: Bearer YOUR_API_KEY` header

Example API call with Bearer token:
Example GraphQL call with a Bearer token:
```bash
curl -H "Authorization: Bearer YOUR_API_KEY" http://localhost:3000/api/functions
curl -H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"query":"{ functions(limit: 20, offset: 0) { nodes { id name } } }"}' \
http://localhost:3000/graphql
```

Note: Function execution endpoints (`/fn/{id}`) do not require authentication.

The handful of endpoints that stay REST β€” cookie login/logout, the CLI device
authorization flow, and function execution β€” are documented in
[`docs/rest-endpoints.md`](docs/rest-endpoints.md).

## CLI

Lunar ships a command-line client (`lunar-cli`) that is auto-generated from the OpenAPI spec, so it always stays in sync with the API.
Lunar ships a command-line client (`lunar-cli`) built on the server's GraphQL API, so it always stays in sync with the schema.

### Installation

Expand Down Expand Up @@ -409,10 +417,13 @@ lunar-cli invoke <function-id> --method POST --body - # read body from stdin

### Keeping the CLI in Sync with the API

The CLI commands are auto-generated from `internal/api/docs/openapi.yaml`. When the API changes, regenerate with:
The CLI talks to the server's GraphQL API (`/graphql`) using a thin
[hasura/go-graphql-client](https://github.com/hasura/go-graphql-client) wrapper.
The GraphQL schema is the single source of truth: the server won't compile until
every field has a resolver, and the CLI's queries are checked against the live
schema by introspection. After changing a command, rebuild with:

```bash
mise run generate-cli
mise run build-cli
```

Expand Down
2 changes: 2 additions & 0 deletions cmd/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"github.com/dimiro1/lunar/internal/config"
internalcron "github.com/dimiro1/lunar/internal/cron"
"github.com/dimiro1/lunar/internal/engine"
"github.com/dimiro1/lunar/internal/graph"
"github.com/dimiro1/lunar/internal/housekeeping"
"github.com/dimiro1/lunar/internal/migrate"
"github.com/dimiro1/lunar/internal/runner"
Expand Down Expand Up @@ -79,6 +80,7 @@ func appOptions() fx.Option {
engine.Module,
internalcron.Module,
housekeeping.Module,
graph.Module,
api.Module,
)
}
Expand Down
70 changes: 70 additions & 0 deletions docs/adr/0012-graphql-management-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# 0012. GraphQL for the management API

- Status: Accepted
- Date: 2026-06-03

## Context

Lunar's management API (functions, versions, executions, tokens) was a
hand-written REST surface under `/api/*`, described by a hand-maintained
OpenAPI document at `internal/api/docs/openapi.yaml`. Nothing connected the two:
the spec was a *document* kept in step with the handlers by discipline, not by
the compiler, so the two could β€” and did β€” drift. The CLI was generated *from*
that spec (oapi-codegen for the client, a custom `lunar-cli/tools/gen` generator
for the Cobra commands), inheriting any drift the spec carried.

Two concrete problems pushed us off this design:

- **Overfetching.** `GET /api/functions` returned every function's full active
version β€” including the entire Lua source β€” plus its env and KV maps, just to
render a list of names and statuses.
- **No enforced contract.** A field added to a handler but not the spec (or vice
versa) compiled and shipped. A latent example surfaced during the migration:
the KV editor's request shape had drifted from what the REST handler accepted.

## Decision

We will serve the entire management API over **GraphQL** using
[`gqlgen`](https://github.com/99designs/gqlgen), with the schema in
`internal/graph/schema/*.graphqls` as the single source of truth. gqlgen
generates the resolver interfaces, so the Go compiler refuses to build until
every schema field has an implementation β€” drift becomes a compile error rather
than a review responsibility. `gqlgen.yml` binds GraphQL types directly to the
existing `internal/store` structs, so there are no duplicate DTOs.

GraphQL is mounted at `POST /graphql` (behind the same auth middleware as the
old REST routes) with a public GraphiQL playground at `GET /graphql`, following
the per-subsystem `fx.Module` pattern from
[ADR-0006](0006-dependency-injection-with-fx.md). The frontend (`frontend/js/api.js`)
and the CLI (via `hasura/go-graphql-client`) both consume it; validation logic
shared by the resolvers lives in `internal/validation`.

Two endpoints deliberately **stay REST**, because they do not fit a query
language:

- `/fn/{id}` β€” public function invocation with arbitrary verbs, paths, request
bodies, and a verbatim response passthrough.
- `/api/auth/*` β€” login/logout (HttpOnly cookies) and the CLI device-authorization
flow, which runs *before* a caller is authenticated.

We removed the REST `/api/*` management handlers, the hand-written
`openapi.yaml`, the Swagger `/docs` UI, and the oapi-codegen + `tools/gen` CLI
generators.

## Consequences

- The schema can no longer drift from the code: adding a field without a
resolver fails to compile, and removing one leaves dead code the compiler
flags.
- The overfetch is gone β€” list views select only the fields they render, and
env/KV/source are lazy field resolvers fetched only when asked for.
- Clients collapse multi-call detail views into a single query, and the CLI is
now hand-written Go (no codegen step) validated against the live schema by
introspection.
- We trade Swagger's familiarity and REST's HTTP-status semantics for GraphQL's
`200 + errors` model; clients translate the `errors` array, and the auth
middleware still returns a real HTTP 401 ahead of the resolver.
- Field resolvers can be N+1 if env/KV are ever selected across a list; at
SQLite scale this is fine, and dataloaders remain an option if it ever isn't.
- Timestamps are still carried as `Int`; introducing a dedicated
`Timestamp`/`Int64` scalar is deferred follow-up work.
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,4 @@ to reverse-engineer it or interrupt the people who were there.
| [0009](0009-lua-as-the-function-language.md) | Lua as the function language | Accepted |
| [0010](0010-in-house-i18n.md) | In-house i18n with locale modules | Accepted |
| [0011](0011-testing-strategy.md) | Layered testing strategy without a JS test runner | Accepted |
| [0012](0012-graphql-management-api.md) | GraphQL for the management API | Accepted |
136 changes: 136 additions & 0 deletions docs/rest-endpoints.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
# REST endpoints

Lunar's management API (functions, versions, executions, tokens) is served over
**GraphQL** at `/graphql` β€” see [ADR-0012](adr/0012-graphql-management-api.md)
and the GraphiQL playground at `GET /graphql` for that surface.

A few endpoints stay REST because they don't fit a query language. They are
intentionally few, and β€” unlike the GraphQL schema, which the compiler keeps in
sync β€” they are documented here by hand. This file is that reference.

All request and response bodies are JSON unless noted.

## Cookie authentication (dashboard)

Used by the web dashboard. The cookie carries the admin API key.

### `POST /api/auth/login`

No auth required.

Request:

```json
{ "apiKey": "<admin API key>" }
```

Responses:

- `200` β€” `{ "success": true }`, plus a `Set-Cookie: auth_token=...` (HttpOnly,
SameSite=Strict, `Secure` when served over HTTPS, 1-day expiry).
- `400` β€” `{ "success": false, "error": "Invalid request body" }`
- `401` β€” `{ "success": false, "error": "Invalid API key" }`

### `POST /api/auth/logout`

No auth required. Clears the `auth_token` cookie.

- `200` β€” `{ "success": true }`

## Device authorization (CLI login)

An OAuth-style device flow so the CLI can obtain an API token without the user
pasting one. The CLI calls `device-request`, the user approves in the dashboard
(which calls `device-approve`), and the CLI polls `device-token` until a token
is issued. See `lunar-cli login`.

### `POST /api/auth/device-request`

No auth required. Starts a flow.

- `200`:

```json
{
"device_code": "<opaque>",
"user_code": "ABCD2345",
"approval_url": "<base URL>/#!/device-approve/<device_code>",
"expires_in": 300,
"interval": 5
}
```

`expires_in` and `interval` are seconds; the device code lives for 5 minutes.

### `GET /api/auth/device-token?code=<device_code>`

No auth required. Polled by the CLI every `interval` seconds.

- `200` β€” `{ "status": "pending" }` while waiting,
`{ "status": "denied" }` if rejected, or
`{ "status": "approved", "token": "<API token>" }` once approved.
- `400` β€” missing `code`.
- `404` β€” unknown or expired `code`.

### `GET /api/auth/device-approve?code=<device_code>`

**Auth required** (dashboard cookie/bearer). Returns the pending request so the
SPA can render the approval screen.

- `200`:

```json
{
"device_code": "<opaque>",
"user_code": "ABCD2345",
"status": "pending",
"expires_at": 1780000000
}
```

- `400` β€” missing `code`; `404` β€” unknown or expired `code`.

### `POST /api/auth/device-approve`

**Auth required.** The dashboard approves or denies a pending request. On
approval a new API token named `CLI (<user_code>)` is created and handed to the
polling CLI.

Request:

```json
{ "device_code": "<opaque>", "action": "allow" }
```

`action` is `"allow"` or `"deny"`.

- `200` β€” `{ "success": true }`
- `400` β€” missing `device_code` or invalid `action`.
- `404` β€” unknown or expired `device_code`.

## Function execution

### `<METHOD> /fn/{function_id}` and `/fn/{function_id}/{path...}`

**No auth required** β€” functions are public. This is a passthrough: any HTTP
method, path suffix, headers, query, and body are delivered to the function as
its event, and the function's HTTP response (status, headers, body) is relayed
back verbatim.

Request headers Lunar interprets:

- `X-Trigger: cron` records the execution's trigger as `cron` (default is
`http`).

Response headers Lunar adds:

- `X-Function-Id`, `X-Function-Version-Id`, `X-Execution-Id`
- `X-Execution-Duration-Ms`

Status codes:

- `2xx`/whatever the function returns on success (defaults to `200` and
`Content-Type: application/json` if the function doesn't set them).
- `404` β€” function not found.
- `403` β€” function disabled.
- `500` β€” no active version, or the function errored during execution.
Loading
Loading