diff --git a/api/openapi.yaml b/api/openapi.yaml index 5610aab1..2b6c3508 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -1309,6 +1309,8 @@ components: HealthResponseBody: additionalProperties: false properties: + api_schema_version: + type: string db_path: type: string ok: diff --git a/docs/reference/http-api.md b/docs/reference/http-api.md new file mode 100644 index 00000000..4d65c60a --- /dev/null +++ b/docs/reference/http-api.md @@ -0,0 +1,83 @@ +# HTTP API schema + +The daemon exposes an HTTP API (used by the CLI, TUI, and remote clients). Its +shape is published as an OpenAPI 3.1 document so out-of-process clients can +generate typed clients instead of hand-copying wire structs. + +## Getting the schema + +The schema is committed at [`api/openapi.yaml`](https://github.com/kenn-io/kata/blob/main/api/openapi.yaml) +and regenerated by the `kata` binary: + +```sh +kata openapi > openapi.yaml +``` + +`kata openapi` builds the document in-process from the daemon's route +definitions, so it needs neither a running daemon nor a database. The runtime +`/openapi.json` route stays disabled; the committed artifact and this command +are the supported way to obtain the schema. + +## What the schema is + +A **per-release snapshot** of the daemon's current HTTP API — a faithful +description of the routes and wire types as they exist at that commit. It is +useful for client generation and review. + +It is **not** a promise of a forever-stable API. Treat it as the contract for +the daemon version you generated it against, not a guarantee that a future +daemon will accept the same calls. + +## Detecting the API version + +The schema carries a version in its `info.version` field +(`APISchemaVersion`). The same value is reported at runtime by +[`GET /api/v1/health`](#) as `api_schema_version`: + +```json +{ + "ok": true, + "schema_version": 7, + "api_schema_version": "0.1.0", + "version": "1.4.2", + "uptime": "5m0s", + "db_path": "/path/to/kata.db" +} +``` + +Three distinct version fields appear here; they answer different questions: + +| Field | Meaning | +| --- | --- | +| `api_schema_version` | The HTTP API contract version — match this against the schema you generated your client from. | +| `schema_version` | The database/storage schema version (`meta.schema_version`), an internal storage concern. | +| `version` | The daemon build version. | + +A client can read `api_schema_version` once at startup and decide whether it +recognizes the daemon's API before issuing further calls. + +The field is **optional in the schema** even though current daemons always send +it. That is deliberate: a version-detection field has to survive version skew, +so a client generated from a schema that includes it can still parse the +response of an older daemon that predates it. Treat an **absent or empty** +`api_schema_version` as "a daemon older than this field," not a parse error. + +## Compatibility expectations + +These are the current intentions, not a contractual guarantee: + +- **Additive changes are not signalled.** New endpoints, and new optional + response fields, can appear without an `api_schema_version` change. Clients + should ignore unknown fields rather than fail on them. +- **Breaking changes bump `api_schema_version`.** Removing or renaming a field, + changing a field's type, or removing an endpoint is a breaking change and is + signalled by a change to `api_schema_version`. A client that pins or checks + the value it was generated against can detect the mismatch instead of failing + at an arbitrary call site. +- **Regeneration stays honest.** A committed golden test fails if + `api/openapi.yaml` drifts from the routes, so the published schema cannot + silently fall out of date with the daemon it describes. + +If you build against this schema and hit a gap, please open an issue — the +contract is meant to be useful to external clients, and feedback shapes how far +the compatibility guarantees are taken. diff --git a/docs/zensical.toml b/docs/zensical.toml index fe36b42f..e1bf21d1 100644 --- a/docs/zensical.toml +++ b/docs/zensical.toml @@ -26,6 +26,7 @@ nav = [ {"CLI" = "reference/cli.md"}, {"Configuration" = "reference/configuration.md"}, {"Agent output format" = "reference/agent-output.md"}, + {"HTTP API schema" = "reference/http-api.md"}, ]}, {"Workflows" = [ {"Agent workflows" = "workflows/agents.md"}, diff --git a/internal/api/types.go b/internal/api/types.go index 8d944109..7dae689e 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -19,14 +19,25 @@ type PingResponse struct { } // HealthResponse mirrors /api/v1/health. +// +// SchemaVersion is the database/storage schema version (meta.schema_version); +// APISchemaVersion is the version stamped into the daemon's OpenAPI document, +// letting an external client detect the HTTP API contract it is talking to. +// +// APISchemaVersion is schema-optional (omitempty) on purpose: the field exists +// to detect version skew, so it must itself survive it. A client generated from +// a schema that carries this field still needs to parse the response of an older +// daemon that predates it — an absent value means "older than this field" rather +// than a parse failure. Current daemons always populate it. type HealthResponse struct { Body struct { - OK bool `json:"ok"` - DBPath string `json:"db_path"` - SchemaVersion int `json:"schema_version"` - Version string `json:"version"` - Uptime string `json:"uptime"` - StartedAt time.Time `json:"started_at"` + OK bool `json:"ok"` + DBPath string `json:"db_path"` + SchemaVersion int `json:"schema_version"` + APISchemaVersion string `json:"api_schema_version,omitempty"` + Version string `json:"version"` + Uptime string `json:"uptime"` + StartedAt time.Time `json:"started_at"` } } diff --git a/internal/daemon/handlers_health.go b/internal/daemon/handlers_health.go index 821b5e66..f7b5f22c 100644 --- a/internal/daemon/handlers_health.go +++ b/internal/daemon/handlers_health.go @@ -42,6 +42,7 @@ func registerHealthHandlers(humaAPI huma.API, cfg ServerConfig) { out.Body.OK = true out.Body.DBPath = cfg.DB.Path() out.Body.SchemaVersion = schema + out.Body.APISchemaVersion = APISchemaVersion out.Body.Version = version.Version out.Body.StartedAt = cfg.StartedAt out.Body.Uptime = time.Since(cfg.StartedAt).Round(time.Second).String() diff --git a/internal/daemon/handlers_health_test.go b/internal/daemon/handlers_health_test.go index 977bec8f..3c8b0c5f 100644 --- a/internal/daemon/handlers_health_test.go +++ b/internal/daemon/handlers_health_test.go @@ -6,6 +6,7 @@ import ( "github.com/stretchr/testify/assert" + "go.kenn.io/kata/internal/daemon" "go.kenn.io/kata/internal/db" ) @@ -13,14 +14,17 @@ func TestHealth_ReportsSchemaAndUptime(t *testing.T) { ts, _ := startDefaultTestServer(t) var body struct { - OK bool `json:"ok"` - SchemaVersion int `json:"schema_version"` - Uptime string `json:"uptime"` - DBPath string `json:"db_path"` + OK bool `json:"ok"` + SchemaVersion int `json:"schema_version"` + APISchemaVersion string `json:"api_schema_version"` + Uptime string `json:"uptime"` + DBPath string `json:"db_path"` } getAndUnmarshal(t, ts, "/api/v1/health", http.StatusOK, &body) assert.True(t, body.OK) assert.Equal(t, db.CurrentSchemaVersion(), body.SchemaVersion) + assert.Equal(t, daemon.APISchemaVersion, body.APISchemaVersion) + assert.NotEmpty(t, body.APISchemaVersion) assert.NotEmpty(t, body.Uptime) assert.NotEmpty(t, body.DBPath) }