mirror of
https://github.com/renorris/openfsd
synced 2026-08-12 20:47:41 +08:00
web: OpenFSD-API-Version middleware, discovery, embed OpenAPI, baseline goldens
Add date microversion header, version-agnostic discovery, embedded OpenAPI, and baseline golden fixtures for enveloped /api/v1 routes. Zero intentional breaking behavior change.
This commit is contained in:
2
go.mod
2
go.mod
@@ -15,6 +15,7 @@ require (
|
||||
go.uber.org/atomic v1.11.0
|
||||
golang.org/x/crypto v0.54.0
|
||||
golang.org/x/sys v0.47.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.54.0
|
||||
)
|
||||
|
||||
@@ -58,7 +59,6 @@ require (
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/libc v1.74.3 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
|
||||
Part of the single `openfsd` binary (`cmd/openfsd -web`; default runs FSD + web). Shares `internal/db` with the FSD server; live connection state comes from the FSD service HTTP API (`FSD_HTTP_SERVICE_ADDRESS`, default `http://127.0.0.1:13618`).
|
||||
|
||||
JSON under `/api/v1` for external tools and map polling. First-party UI is a progressive-enhancement MPA: form login sets a signed **HttpOnly session cookie**; `/api/v1` dual-accepts that cookie **or** a Bearer access token. External tools should use Bearer API tokens.
|
||||
JSON under `/api/v1` for **operator automation** and map polling. First-party UI is a progressive-enhancement MPA: form login sets a signed **HttpOnly session cookie**; `/api/v1` dual-accepts that cookie **or** a Bearer access token. Operators automating the same workflows as the UI should use **Bearer API tokens** (not browser session cookies).
|
||||
|
||||
**Blast radius:** Admin-minted API tokens currently carry an **Administrator-equivalent** network rating claim and full config/token power. Treat them as operator credentials with admin blast radius until fine-grained scopes exist—not as multi-tenant “third-party app” keys.
|
||||
|
||||
### First-party HTML pages (no-JS primary path)
|
||||
| Page | Routes | Authz |
|
||||
@@ -22,7 +24,7 @@ JSON under `/api/v1` remains for external consumers and map polling. Session dua
|
||||
### Airport editor validation
|
||||
|
||||
- **Live client:** JS `parseAPT` / `parseAIR` + soft cross-file warnings (dep ICAO, aircraft far from field) on the Validate tab.
|
||||
- **Confirm with server (optional):** `POST /api/v1/editor/validate-apt` and `POST /api/v1/editor/validate-air` with JSON `{"text":"…"}` (Admin, dual-accept Bearer | cookie; CSRF when cookie). Response is standard `APIV1Response` with `data.errors`, plus `icao` / `surface_count` or `aircraft_count`. Transient request body only — never written to disk/DB.
|
||||
- **Confirm with server (optional):** `POST /api/v1/editor/validate-apt` and `POST /api/v1/editor/validate-air` with JSON `{"text":"…"}` (**Instructor1+**, dual-accept Bearer | cookie; CSRF when cookie). Response is standard `APIV1Response` with `data.errors`, plus `icao` / `surface_count` or `aircraft_count`. Transient request body only — never written to disk/DB.
|
||||
- **Handoff:** download `.apt`/`.air`, then load on `/sweatbox` (no automatic push from editor → live session).
|
||||
- Design: `docs/design/apt-air-editor.md`. JS unit tests: `webjs/` + `bash scripts/check-webjs.sh`.
|
||||
|
||||
@@ -60,9 +62,48 @@ Most endpoints accept a valid JWT access token:
|
||||
```
|
||||
Authorization: Bearer <access_token>
|
||||
```
|
||||
- **API tokens** can be created via `/api/v1/config/createtoken` with a custom expiry date. See the **Server Configuration** menu in the frontend UI to generate one.
|
||||
- **API tokens** can be created via `/api/v1/config/createtoken` (Administrator) with a custom expiry date (max **90 days**). See the **Server Configuration** menu in the frontend UI to generate one.
|
||||
- `createtoken` responses include additive `recommended_api_version`, `api_version_min`, and `api_version_max` so clients can pin the microversion header.
|
||||
- Bearer-authenticated clients do **not** need CSRF (CSRF applies only when the request is authenticated via the session cookie).
|
||||
- Dual-accept: a **valid** Bearer token wins over a session cookie; a garbage Bearer header does **not** disable CSRF if the session cookie is what authenticates the request.
|
||||
- **Operator automation:** prefer minted API tokens over `/auth/login` or `/auth/refresh`. Tokens are admin-equivalent until scopes exist—store as secrets; rotate on compromise via secret reset.
|
||||
|
||||
---
|
||||
|
||||
## API versioning
|
||||
|
||||
openfsd keeps a durable URL major **`/api/v1`**. Rare **breaking** changes on **Stable** enveloped routes introduce a date **microversion** selected with the request header:
|
||||
|
||||
```http
|
||||
OpenFSD-API-Version: 2026-07-28
|
||||
```
|
||||
|
||||
| Rule | Detail |
|
||||
|------|--------|
|
||||
| Canonical form | `YYYY-MM-DD` after normalize; also accept `1.YYYYMMDD` and `latest` (→ max) |
|
||||
| Default when omitted | **`max_version` (current)**; response sets `OpenFSD-API-Version-Defaulted: true` |
|
||||
| Production clients | **MUST** send `OpenFSD-API-Version` with a supported pin |
|
||||
| Additive changes | Free (new fields/endpoints); clients must ignore unknown JSON keys |
|
||||
| Breaking changes (Stable) | Bump microversion; keep old pins for the support window |
|
||||
| Envelope body `version` | Always major `"v1"`; microversion is **header-only** |
|
||||
| Response headers | `OpenFSD-API-Version` (effective), `OpenFSD-API-Min-Version`, `OpenFSD-API-Max-Version`, optional `OpenFSD-API-Version-Defaulted`, `Vary: OpenFSD-API-Version` |
|
||||
|
||||
**Discovery (public, version-agnostic — never 400 on a bad pin):**
|
||||
|
||||
| Method | Path | Notes |
|
||||
|--------|------|-------|
|
||||
| GET | `/api/v1` | Same discovery payload as `/versions` |
|
||||
| GET | `/api/v1/versions` | `min_version`, `max_version`, `versions[]`, `openapi` |
|
||||
| GET | `/api/v1/openapi.json` | OpenAPI 3 from embedded YAML |
|
||||
| GET | `/api/v1/openapi.yaml` | Canonical embed source |
|
||||
|
||||
Canonical OpenAPI file: `internal/web/openapi/openapi.v1.yaml` (`//go:embed`). No mirrored copy under `docs/`.
|
||||
|
||||
**Outside microversion reject:** `/api/v1/data/*`, `/api/v1/fsd-jwt`, auth login/refresh, discovery, OpenAPI. Resource groups (`/user`, `/config`, `/fsdconn`, `/sweatbox`, `/editor`) reject unknown/invalid pins with **400** envelope.
|
||||
|
||||
**Stability tiers:** existing enveloped user/config/fsdconn/editor routes are **Stable** (goldens under `testdata/api_v1/<pin>/`). New expansion routes may ship **Provisional** (shape may change without a microversion bump while Provisional). Design: `docs/design/rest-api-versioning.md`.
|
||||
|
||||
Baseline pin (first supported): **`2026-07-28`**.
|
||||
|
||||
---
|
||||
|
||||
@@ -100,6 +141,29 @@ Common HTTP status codes:
|
||||
|
||||
## Endpoints
|
||||
|
||||
### Discovery & OpenAPI
|
||||
|
||||
#### GET /api/v1 and GET /api/v1/versions
|
||||
Public discovery of supported microversions (same payload on both paths). **Version-agnostic:** a bad `OpenFSD-API-Version` does not cause 400.
|
||||
|
||||
**Response (200 OK)** `data`:
|
||||
```json
|
||||
{
|
||||
"major": "v1",
|
||||
"min_version": "2026-07-28",
|
||||
"max_version": "2026-07-28",
|
||||
"versions": ["2026-07-28"],
|
||||
"header": "OpenFSD-API-Version",
|
||||
"default": "max",
|
||||
"openapi": "/api/v1/openapi.json"
|
||||
}
|
||||
```
|
||||
|
||||
#### GET /api/v1/openapi.json and GET /api/v1/openapi.yaml
|
||||
Public OpenAPI 3 document (embedded). Version-agnostic.
|
||||
|
||||
---
|
||||
|
||||
### Authentication
|
||||
|
||||
#### POST /api/v1/auth/login
|
||||
@@ -410,7 +474,9 @@ Upon successfully calling this endpoint, this effectively invalidates *all* prev
|
||||
---
|
||||
|
||||
#### POST /api/v1/config/createtoken
|
||||
Create a new API access token with a specified expiry.
|
||||
Create a new API access token with a specified expiry (max 90 days).
|
||||
|
||||
**Blast radius:** minted token claims **Administrator** network rating. Treat as a full operator credential.
|
||||
|
||||
**Request Body**:
|
||||
```json
|
||||
@@ -425,13 +491,16 @@ Create a new API access token with a specified expiry.
|
||||
"version": "v1",
|
||||
"err": null,
|
||||
"data": {
|
||||
"token": string // JWT access token
|
||||
"token": string, // JWT access token
|
||||
"recommended_api_version": string, // pin production clients should send
|
||||
"api_version_min": string,
|
||||
"api_version_max": string
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Errors**:
|
||||
- **400 Bad Request**: Invalid JSON body or expiry date in the past.
|
||||
- **400 Bad Request**: Invalid JSON body, expiry in the past, or expiry more than 90 days out.
|
||||
- **401 Unauthorized**: Invalid bearer token.
|
||||
- **403 Forbidden**: Insufficient permissions (Administrator rating required).
|
||||
- **500 Internal Server Error**: Error generating or signing token.
|
||||
|
||||
@@ -36,7 +36,7 @@ type editorValidateAIRData struct {
|
||||
// Authz is inline I1+ → 403 JSON (never HTML redirect).
|
||||
func (s *Server) setupEditorAPIRoutes(parent *gin.RouterGroup) {
|
||||
g := parent.Group("/editor")
|
||||
g.Use(s.jwtBearerMiddleware, s.csrfIfCookieSession)
|
||||
s.useAPIV1Protected(g)
|
||||
g.POST("/validate-apt", s.handleAPIValidateAPT)
|
||||
g.POST("/validate-air", s.handleAPIValidateAIR)
|
||||
}
|
||||
|
||||
@@ -70,10 +70,18 @@ func (s *Server) handleCreateNewAPIToken(c *gin.Context) {
|
||||
}
|
||||
|
||||
type ResponseBody struct {
|
||||
Token string `json:"token"`
|
||||
Token string `json:"token"`
|
||||
RecommendedAPIVersion string `json:"recommended_api_version"`
|
||||
APIVersionMin string `json:"api_version_min"`
|
||||
APIVersionMax string `json:"api_version_max"`
|
||||
}
|
||||
|
||||
resBody := ResponseBody{Token: accessTokenStr}
|
||||
resBody := ResponseBody{
|
||||
Token: accessTokenStr,
|
||||
RecommendedAPIVersion: apiMicroMax,
|
||||
APIVersionMin: apiMicroMin,
|
||||
APIVersionMax: apiMicroMax,
|
||||
}
|
||||
res := newAPIV1Success(&resBody)
|
||||
writeAPIV1Response(c, http.StatusCreated, &res)
|
||||
}
|
||||
|
||||
@@ -47,6 +47,9 @@ func bindJSONOrAbort(c *gin.Context, reqBody any) (ok bool) {
|
||||
}
|
||||
|
||||
func writeAPIV1Response(c *gin.Context, code int, res *APIV1Response) {
|
||||
// Soft / hard version headers when context is set; safe defaults otherwise.
|
||||
setAPIVersionHeaders(c)
|
||||
|
||||
resBody, err := json.Marshal(res)
|
||||
if err != nil {
|
||||
c.Writer.Header().Set("Content-Type", "text/plain")
|
||||
|
||||
287
internal/web/api_version.go
Normal file
287
internal/web/api_version.go
Normal file
@@ -0,0 +1,287 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Package-level API microversion registry (KD-3, KD-5, KD-19).
|
||||
// Baseline is the UTC merge date of the versioning foundation PR.
|
||||
const (
|
||||
apiMajorVersion = "v1"
|
||||
|
||||
// First supported pin / current max (KD-19: UTC calendar date of PR-1 merge).
|
||||
apiMicroMin = "2026-07-28"
|
||||
apiMicroMax = "2026-07-28"
|
||||
|
||||
headerAPIVersion = "OpenFSD-API-Version"
|
||||
headerAPIMinVersion = "OpenFSD-API-Min-Version"
|
||||
headerAPIMaxVersion = "OpenFSD-API-Max-Version"
|
||||
headerAPIVersionDefaulted = "OpenFSD-API-Version-Defaulted" // "true" if header omitted
|
||||
|
||||
apiVersionContextKey = "api_version"
|
||||
)
|
||||
|
||||
// knownMicroversions lists every still-supported pin in ascending YYYY-MM-DD order.
|
||||
var knownMicroversions = []string{
|
||||
"2026-07-28",
|
||||
}
|
||||
|
||||
var (
|
||||
errAPIVersionInvalid = errors.New("invalid OpenFSD-API-Version")
|
||||
errAPIVersionUnsupported = errors.New("unsupported OpenFSD-API-Version")
|
||||
|
||||
reAPIVersionDate = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
|
||||
reAPIVersionAlias = regexp.MustCompile(`^1\.(\d{8})$`)
|
||||
)
|
||||
|
||||
// apiVersionContext holds the resolved microversion for a request.
|
||||
type apiVersionContext struct {
|
||||
Requested string // raw header (may be empty)
|
||||
Effective string // normalized pin used for shaping
|
||||
Defaulted bool // true if header omitted (or empty after trim)
|
||||
}
|
||||
|
||||
// normalizeAPIVersion converts a client version token into canonical YYYY-MM-DD.
|
||||
// raw is the header value after the middleware has established it is non-empty.
|
||||
// max is apiMicroMax (used for the "latest" alias).
|
||||
// Membership in knownMicroversions is checked separately by the middleware.
|
||||
func normalizeAPIVersion(raw, max string) (canonical string, err error) {
|
||||
s := strings.TrimSpace(raw)
|
||||
if s == "" {
|
||||
return "", errAPIVersionInvalid
|
||||
}
|
||||
|
||||
// "latest" (any ASCII case) → max
|
||||
if strings.EqualFold(s, "latest") {
|
||||
if max == "" {
|
||||
return "", errAPIVersionInvalid
|
||||
}
|
||||
return max, nil
|
||||
}
|
||||
|
||||
// Alias: 1.YYYYMMDD → YYYY-MM-DD
|
||||
if m := reAPIVersionAlias.FindStringSubmatch(s); m != nil {
|
||||
digits := m[1]
|
||||
s = digits[0:4] + "-" + digits[4:6] + "-" + digits[6:8]
|
||||
}
|
||||
|
||||
if !reAPIVersionDate.MatchString(s) {
|
||||
return "", errAPIVersionInvalid
|
||||
}
|
||||
|
||||
// Reject impossible calendar dates (e.g. 2026-02-30).
|
||||
if _, parseErr := time.ParseInLocation("2006-01-02", s, time.UTC); parseErr != nil {
|
||||
return "", errAPIVersionInvalid
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func isKnownMicroversion(canonical string) bool {
|
||||
for _, v := range knownMicroversions {
|
||||
if v == canonical {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// shapeAdapter is one ordered microversion DTO shaper (KD-12).
|
||||
// introducedAt is YYYY-MM-DD (or "epoch"); adapters are sorted ascending.
|
||||
type shapeAdapter[T any, D any] struct {
|
||||
introducedAt string
|
||||
shape func(T) D
|
||||
}
|
||||
|
||||
// shapeFor selects the newest adapter with introducedAt <= effective (lexicographic ISO dates).
|
||||
func shapeFor[T any, D any](effective string, adapters []shapeAdapter[T, D], domain T) D {
|
||||
if len(adapters) == 0 {
|
||||
var zero D
|
||||
return zero
|
||||
}
|
||||
for i := len(adapters) - 1; i >= 0; i-- {
|
||||
if adapters[i].introducedAt <= effective {
|
||||
return adapters[i].shape(domain)
|
||||
}
|
||||
}
|
||||
return adapters[0].shape(domain)
|
||||
}
|
||||
|
||||
// useAPIV1Protected attaches dual-accept auth + CSRF + API microversion middleware.
|
||||
// PR-2 will add Bearer actor revalidation here.
|
||||
func (s *Server) useAPIV1Protected(g *gin.RouterGroup) {
|
||||
g.Use(
|
||||
s.jwtBearerMiddleware,
|
||||
s.csrfIfCookieSession,
|
||||
s.apiVersionMiddleware,
|
||||
)
|
||||
}
|
||||
|
||||
// apiVersionMiddleware resolves OpenFSD-API-Version for dual-accept resource groups.
|
||||
// Unknown/invalid pins → 400 envelope. Omitted header → max + Defaulted.
|
||||
func (s *Server) apiVersionMiddleware(c *gin.Context) {
|
||||
raw := c.GetHeader(headerAPIVersion)
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
|
||||
ctx := apiVersionContext{Requested: raw}
|
||||
|
||||
if trimmed == "" {
|
||||
ctx.Effective = apiMicroMax
|
||||
ctx.Defaulted = true
|
||||
c.Set(apiVersionContextKey, ctx)
|
||||
setAPIVersionHeaders(c)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
canonical, err := normalizeAPIVersion(trimmed, apiMicroMax)
|
||||
if err != nil {
|
||||
setAPIVersionHeadersExplicit(c, apiMicroMax, true /* still show registry */)
|
||||
res := newAPIV1Failure("invalid OpenFSD-API-Version")
|
||||
writeAPIV1Response(c, http.StatusBadRequest, &res)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
if !isKnownMicroversion(canonical) {
|
||||
setAPIVersionHeadersExplicit(c, apiMicroMax, true)
|
||||
msg := fmt.Sprintf("unsupported OpenFSD-API-Version %q; min=%s max=%s",
|
||||
canonical, apiMicroMin, apiMicroMax)
|
||||
res := newAPIV1Failure(msg)
|
||||
writeAPIV1Response(c, http.StatusBadRequest, &res)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Effective = canonical
|
||||
ctx.Defaulted = false
|
||||
c.Set(apiVersionContextKey, ctx)
|
||||
setAPIVersionHeaders(c)
|
||||
c.Next()
|
||||
}
|
||||
|
||||
// getAPIVersionContext returns the resolved microversion, if any.
|
||||
func getAPIVersionContext(c *gin.Context) (apiVersionContext, bool) {
|
||||
val, ok := c.Get(apiVersionContextKey)
|
||||
if !ok {
|
||||
return apiVersionContext{}, false
|
||||
}
|
||||
ctx, ok := val.(apiVersionContext)
|
||||
return ctx, ok
|
||||
}
|
||||
|
||||
// effectiveAPIVersion returns the effective pin, or apiMicroMax when unset.
|
||||
func effectiveAPIVersion(c *gin.Context) string {
|
||||
if ctx, ok := getAPIVersionContext(c); ok && ctx.Effective != "" {
|
||||
return ctx.Effective
|
||||
}
|
||||
return apiMicroMax
|
||||
}
|
||||
|
||||
// setAPIVersionHeaders writes version response headers from gin context.
|
||||
// Safe no-op-with-soft-defaults when the version context is missing (public routes).
|
||||
func setAPIVersionHeaders(c *gin.Context) {
|
||||
if ctx, ok := getAPIVersionContext(c); ok {
|
||||
setAPIVersionHeadersValues(c, ctx.Effective, ctx.Defaulted)
|
||||
return
|
||||
}
|
||||
// Soft observability headers on public / envelope responses without hard pin.
|
||||
setAPIVersionHeadersValues(c, apiMicroMax, false)
|
||||
}
|
||||
|
||||
// setAPIVersionHeadersExplicit sets registry headers with a chosen effective pin
|
||||
// (used when rejecting a bad pin before context is stored).
|
||||
func setAPIVersionHeadersExplicit(c *gin.Context, effective string, _ bool) {
|
||||
setAPIVersionHeadersValues(c, effective, false)
|
||||
}
|
||||
|
||||
func setAPIVersionHeadersValues(c *gin.Context, effective string, defaulted bool) {
|
||||
if effective == "" {
|
||||
effective = apiMicroMax
|
||||
}
|
||||
h := c.Writer.Header()
|
||||
h.Set(headerAPIVersion, effective)
|
||||
h.Set(headerAPIMinVersion, apiMicroMin)
|
||||
h.Set(headerAPIMaxVersion, apiMicroMax)
|
||||
// Caches must vary on the client pin.
|
||||
h.Set("Vary", headerAPIVersion)
|
||||
if defaulted {
|
||||
h.Set(headerAPIVersionDefaulted, "true")
|
||||
}
|
||||
}
|
||||
|
||||
// discoveryData is the public version-discovery payload (KD-20).
|
||||
type discoveryData struct {
|
||||
Major string `json:"major"`
|
||||
MinVersion string `json:"min_version"`
|
||||
MaxVersion string `json:"max_version"`
|
||||
Versions []string `json:"versions"`
|
||||
Header string `json:"header"`
|
||||
Default string `json:"default"`
|
||||
OpenAPI string `json:"openapi"`
|
||||
}
|
||||
|
||||
func newDiscoveryData() discoveryData {
|
||||
versions := make([]string, len(knownMicroversions))
|
||||
copy(versions, knownMicroversions)
|
||||
return discoveryData{
|
||||
Major: apiMajorVersion,
|
||||
MinVersion: apiMicroMin,
|
||||
MaxVersion: apiMicroMax,
|
||||
Versions: versions,
|
||||
Header: headerAPIVersion,
|
||||
Default: "max",
|
||||
OpenAPI: "/api/v1/openapi.json",
|
||||
}
|
||||
}
|
||||
|
||||
// handleAPIDiscovery serves GET /api/v1 and GET /api/v1/versions (public, version-agnostic).
|
||||
func (s *Server) handleAPIDiscovery(c *gin.Context) {
|
||||
// Never reject on pin; still emit registry headers for observability.
|
||||
setAPIVersionHeadersValues(c, apiMicroMax, false)
|
||||
res := newAPIV1Success(newDiscoveryData())
|
||||
writeAPIV1Response(c, http.StatusOK, &res)
|
||||
}
|
||||
|
||||
//go:embed openapi/openapi.v1.yaml
|
||||
var openAPIYAML []byte
|
||||
|
||||
// handleOpenAPIYAML serves the embedded OpenAPI document as YAML (public, version-agnostic).
|
||||
func (s *Server) handleOpenAPIYAML(c *gin.Context) {
|
||||
setAPIVersionHeadersValues(c, apiMicroMax, false)
|
||||
c.Header("Content-Type", "application/yaml; charset=utf-8")
|
||||
c.Writer.WriteHeader(http.StatusOK)
|
||||
_, _ = c.Writer.Write(openAPIYAML)
|
||||
}
|
||||
|
||||
// handleOpenAPIJSON serves the embedded OpenAPI document converted to JSON.
|
||||
func (s *Server) handleOpenAPIJSON(c *gin.Context) {
|
||||
setAPIVersionHeadersValues(c, apiMicroMax, false)
|
||||
body, err := openAPIYAMLToJSON(openAPIYAML)
|
||||
if err != nil {
|
||||
res := newAPIV1Failure("openapi conversion failed")
|
||||
writeAPIV1Response(c, http.StatusInternalServerError, &res)
|
||||
return
|
||||
}
|
||||
c.Header("Content-Type", "application/json; charset=utf-8")
|
||||
c.Writer.WriteHeader(http.StatusOK)
|
||||
_, _ = c.Writer.Write(body)
|
||||
}
|
||||
|
||||
func openAPIYAMLToJSON(src []byte) ([]byte, error) {
|
||||
var doc any
|
||||
if err := yaml.Unmarshal(src, &doc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(doc)
|
||||
}
|
||||
401
internal/web/api_version_test.go
Normal file
401
internal/web/api_version_test.go
Normal file
@@ -0,0 +1,401 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNormalizeAPIVersion(t *testing.T) {
|
||||
const max = "2026-07-28"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
want string
|
||||
wantErr error
|
||||
}{
|
||||
{name: "dashed date", raw: "2026-07-28", want: "2026-07-28"},
|
||||
{name: "latest lower", raw: "latest", want: max},
|
||||
{name: "latest upper", raw: "LATEST", want: max},
|
||||
{name: "latest mixed", raw: "Latest", want: max},
|
||||
{name: "latest padded", raw: " latest ", want: max},
|
||||
{name: "alias 1.YYYYMMDD", raw: "1.20260728", want: "2026-07-28"},
|
||||
{name: "alias with spaces", raw: " 1.20260728 ", want: "2026-07-28"},
|
||||
{name: "date padded", raw: " 2026-07-28 ", want: "2026-07-28"},
|
||||
|
||||
// Invalid forms
|
||||
{name: "empty", raw: "", wantErr: errAPIVersionInvalid},
|
||||
{name: "whitespace only", raw: " ", wantErr: errAPIVersionInvalid},
|
||||
{name: "mixed alias with dashes", raw: "1.2026-07-28", wantErr: errAPIVersionInvalid},
|
||||
{name: "v1 prefix", raw: "v1.2026-07-28", wantErr: errAPIVersionInvalid},
|
||||
{name: "slash date", raw: "2026/07/28", wantErr: errAPIVersionInvalid},
|
||||
{name: "garbage", raw: "not-a-version", wantErr: errAPIVersionInvalid},
|
||||
{name: "partial date", raw: "2026-07", wantErr: errAPIVersionInvalid},
|
||||
{name: "impossible date Feb 30", raw: "2026-02-30", wantErr: errAPIVersionInvalid},
|
||||
{name: "impossible date Apr 31", raw: "2026-04-31", wantErr: errAPIVersionInvalid},
|
||||
{name: "zero month", raw: "2026-00-15", wantErr: errAPIVersionInvalid},
|
||||
{name: "alias bad day", raw: "1.20260230", wantErr: errAPIVersionInvalid},
|
||||
{name: "alias short", raw: "1.2026072", wantErr: errAPIVersionInvalid},
|
||||
{name: "alias long", raw: "1.202607281", wantErr: errAPIVersionInvalid},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := normalizeAPIVersion(tc.raw, max)
|
||||
if tc.wantErr != nil {
|
||||
require.ErrorIs(t, err, tc.wantErr)
|
||||
assert.Empty(t, got)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShapeFor(t *testing.T) {
|
||||
type domain struct{ N int }
|
||||
type dto struct{ Label string }
|
||||
|
||||
adapters := []shapeAdapter[domain, dto]{
|
||||
{introducedAt: "2026-07-28", shape: func(d domain) dto { return dto{Label: "baseline"} }},
|
||||
{introducedAt: "2026-09-01", shape: func(d domain) dto { return dto{Label: "sept"} }},
|
||||
{introducedAt: "2026-11-01", shape: func(d domain) dto { return dto{Label: "nov"} }},
|
||||
}
|
||||
|
||||
// Walk every still-supported pin (only baseline today) + future pins for matrix.
|
||||
assert.Equal(t, "baseline", shapeFor("2026-07-28", adapters, domain{}).Label)
|
||||
assert.Equal(t, "sept", shapeFor("2026-09-01", adapters, domain{}).Label)
|
||||
assert.Equal(t, "sept", shapeFor("2026-10-15", adapters, domain{}).Label)
|
||||
assert.Equal(t, "nov", shapeFor("2026-11-01", adapters, domain{}).Label)
|
||||
assert.Equal(t, "nov", shapeFor("2027-01-01", adapters, domain{}).Label)
|
||||
// Before first adapter → first adapter (defensive).
|
||||
assert.Equal(t, "baseline", shapeFor("2020-01-01", adapters, domain{}).Label)
|
||||
|
||||
for _, pin := range knownMicroversions {
|
||||
got := shapeFor(pin, adapters, domain{})
|
||||
assert.NotEmpty(t, got.Label, "pin %s must resolve", pin)
|
||||
}
|
||||
|
||||
// Empty adapters → zero value.
|
||||
var empty []shapeAdapter[domain, dto]
|
||||
assert.Equal(t, dto{}, shapeFor("2026-07-28", empty, domain{}))
|
||||
}
|
||||
|
||||
func TestAPIVersionMiddleware_OmitDefaultsMax(t *testing.T) {
|
||||
env := setupTestAPI(t)
|
||||
access, _ := env.login(t, env.admin.CID, env.adminPass)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/config/load", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+access)
|
||||
w := httptest.NewRecorder()
|
||||
env.router.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
||||
assert.Equal(t, apiMicroMax, w.Header().Get(headerAPIVersion))
|
||||
assert.Equal(t, apiMicroMin, w.Header().Get(headerAPIMinVersion))
|
||||
assert.Equal(t, apiMicroMax, w.Header().Get(headerAPIMaxVersion))
|
||||
assert.Equal(t, "true", w.Header().Get(headerAPIVersionDefaulted))
|
||||
assert.Contains(t, w.Header().Get("Vary"), headerAPIVersion)
|
||||
}
|
||||
|
||||
func TestAPIVersionMiddleware_PinnedKnown(t *testing.T) {
|
||||
env := setupTestAPI(t)
|
||||
access, _ := env.login(t, env.admin.CID, env.adminPass)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/config/load", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+access)
|
||||
req.Header.Set(headerAPIVersion, "2026-07-28")
|
||||
w := httptest.NewRecorder()
|
||||
env.router.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
||||
assert.Equal(t, "2026-07-28", w.Header().Get(headerAPIVersion))
|
||||
assert.Empty(t, w.Header().Get(headerAPIVersionDefaulted))
|
||||
}
|
||||
|
||||
func TestAPIVersionMiddleware_LatestAlias(t *testing.T) {
|
||||
env := setupTestAPI(t)
|
||||
access, _ := env.login(t, env.admin.CID, env.adminPass)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/config/load", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+access)
|
||||
req.Header.Set(headerAPIVersion, "LATEST")
|
||||
w := httptest.NewRecorder()
|
||||
env.router.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
||||
assert.Equal(t, apiMicroMax, w.Header().Get(headerAPIVersion))
|
||||
assert.Empty(t, w.Header().Get(headerAPIVersionDefaulted))
|
||||
}
|
||||
|
||||
func TestAPIVersionMiddleware_AliasForm(t *testing.T) {
|
||||
env := setupTestAPI(t)
|
||||
access, _ := env.login(t, env.admin.CID, env.adminPass)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/config/load", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+access)
|
||||
req.Header.Set(headerAPIVersion, "1.20260728")
|
||||
w := httptest.NewRecorder()
|
||||
env.router.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
||||
assert.Equal(t, "2026-07-28", w.Header().Get(headerAPIVersion))
|
||||
}
|
||||
|
||||
func TestAPIVersionMiddleware_UnknownPin400(t *testing.T) {
|
||||
env := setupTestAPI(t)
|
||||
access, _ := env.login(t, env.admin.CID, env.adminPass)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/config/load", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+access)
|
||||
req.Header.Set(headerAPIVersion, "2020-01-01")
|
||||
w := httptest.NewRecorder()
|
||||
env.router.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String())
|
||||
res := decodeAPIV1(t, w)
|
||||
require.NotNil(t, res.Err)
|
||||
assert.Contains(t, *res.Err, "unsupported OpenFSD-API-Version")
|
||||
assert.Contains(t, *res.Err, "2020-01-01")
|
||||
assert.Equal(t, apiMicroMin, w.Header().Get(headerAPIMinVersion))
|
||||
assert.Equal(t, apiMicroMax, w.Header().Get(headerAPIMaxVersion))
|
||||
}
|
||||
|
||||
func TestAPIVersionMiddleware_InvalidPin400(t *testing.T) {
|
||||
env := setupTestAPI(t)
|
||||
access, _ := env.login(t, env.admin.CID, env.adminPass)
|
||||
|
||||
for _, pin := range []string{"1.2026-07-28", "not-a-date", "2026-02-30"} {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/config/load", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+access)
|
||||
req.Header.Set(headerAPIVersion, pin)
|
||||
w := httptest.NewRecorder()
|
||||
env.router.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, w.Code, "pin=%s body=%s", pin, w.Body.String())
|
||||
res := decodeAPIV1(t, w)
|
||||
require.NotNil(t, res.Err)
|
||||
assert.Equal(t, "invalid OpenFSD-API-Version", *res.Err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIDiscovery_VersionAgnostic(t *testing.T) {
|
||||
env := setupTestAPI(t)
|
||||
|
||||
paths := []string{"/api/v1", "/api/v1/", "/api/v1/versions"}
|
||||
// Gin may normalize trailing slash — hit both discovery routes + bad pin.
|
||||
for _, path := range []string{"/api/v1", "/api/v1/versions"} {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
req.Header.Set(headerAPIVersion, "2020-01-01") // would 400 on resource groups
|
||||
w := httptest.NewRecorder()
|
||||
env.router.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code, "path=%s body=%s", path, w.Body.String())
|
||||
res := decodeAPIV1(t, w)
|
||||
require.Nil(t, res.Err)
|
||||
assert.Equal(t, "v1", res.Version)
|
||||
|
||||
// Never 400 for pin on discovery.
|
||||
assert.Equal(t, apiMicroMax, w.Header().Get(headerAPIVersion))
|
||||
assert.Equal(t, apiMicroMin, w.Header().Get(headerAPIMinVersion))
|
||||
assert.Equal(t, apiMicroMax, w.Header().Get(headerAPIMaxVersion))
|
||||
_ = paths
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIDiscovery_PayloadGolden(t *testing.T) {
|
||||
env := setupTestAPI(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/versions", nil)
|
||||
w := httptest.NewRecorder()
|
||||
env.router.ServeHTTP(w, req)
|
||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
||||
assertGoldenJSON(t, "2026-07-28/discovery.json", w.Body.Bytes())
|
||||
}
|
||||
|
||||
func TestOpenAPIEndpoints(t *testing.T) {
|
||||
env := setupTestAPI(t)
|
||||
|
||||
// YAML
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/openapi.yaml", nil)
|
||||
req.Header.Set(headerAPIVersion, "bogus") // must not 400
|
||||
w := httptest.NewRecorder()
|
||||
env.router.ServeHTTP(w, req)
|
||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
||||
assert.Contains(t, w.Header().Get("Content-Type"), "yaml")
|
||||
assert.Contains(t, w.Body.String(), "openapi:")
|
||||
assert.Contains(t, w.Body.String(), "openfsd")
|
||||
|
||||
// JSON
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/openapi.json", nil)
|
||||
req.Header.Set(headerAPIVersion, "2020-01-01")
|
||||
w = httptest.NewRecorder()
|
||||
env.router.ServeHTTP(w, req)
|
||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
||||
assert.Contains(t, w.Header().Get("Content-Type"), "json")
|
||||
var doc map[string]any
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &doc))
|
||||
assert.Contains(t, doc, "openapi")
|
||||
assert.Contains(t, doc, "paths")
|
||||
}
|
||||
|
||||
func TestDataFeed_NoVersionReject(t *testing.T) {
|
||||
env := setupTestAPI(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/data/status.json", nil)
|
||||
req.Header.Set(headerAPIVersion, "2020-01-01")
|
||||
w := httptest.NewRecorder()
|
||||
env.router.ServeHTTP(w, req)
|
||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
func TestCreateToken_RecommendedAPIVersionFields(t *testing.T) {
|
||||
env := setupTestAPI(t)
|
||||
adminAccess, _ := env.login(t, env.admin.CID, env.adminPass)
|
||||
|
||||
w := env.doJSON(t, http.MethodPost, "/api/v1/config/createtoken", map[string]any{
|
||||
"expiry_date_time": time.Now().UTC().Add(2 * time.Hour).Format(time.RFC3339),
|
||||
}, adminAccess)
|
||||
require.Equal(t, http.StatusCreated, w.Code, w.Body.String())
|
||||
res := decodeAPIV1(t, w)
|
||||
require.Nil(t, res.Err)
|
||||
|
||||
data, err := json.Marshal(res.Data)
|
||||
require.NoError(t, err)
|
||||
var body struct {
|
||||
Token string `json:"token"`
|
||||
RecommendedAPIVersion string `json:"recommended_api_version"`
|
||||
APIVersionMin string `json:"api_version_min"`
|
||||
APIVersionMax string `json:"api_version_max"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(data, &body))
|
||||
require.NotEmpty(t, body.Token)
|
||||
assert.Equal(t, apiMicroMax, body.RecommendedAPIVersion)
|
||||
assert.Equal(t, apiMicroMin, body.APIVersionMin)
|
||||
assert.Equal(t, apiMicroMax, body.APIVersionMax)
|
||||
|
||||
// Golden with redacted token.
|
||||
var envelope map[string]any
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &envelope))
|
||||
dataMap, ok := envelope["data"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
dataMap["token"] = "<redacted>"
|
||||
rewritten, err := json.Marshal(envelope)
|
||||
require.NoError(t, err)
|
||||
assertGoldenJSON(t, "2026-07-28/createtoken_fields.json", rewritten)
|
||||
}
|
||||
|
||||
func TestAPIV1Goldens_EnvelopedRoutes(t *testing.T) {
|
||||
env := setupTestAPI(t)
|
||||
obsAccess, _ := env.login(t, env.observer.CID, env.observerPass)
|
||||
adminAccess, _ := env.login(t, env.admin.CID, env.adminPass)
|
||||
|
||||
t.Run("error_unauthorized", func(t *testing.T) {
|
||||
w := env.doJSON(t, http.MethodPost, "/api/v1/user/load", map[string]any{"cid": env.observer.CID}, "")
|
||||
require.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
assertGoldenJSON(t, "2026-07-28/error_unauthorized.json", w.Body.Bytes())
|
||||
})
|
||||
|
||||
t.Run("error_forbidden", func(t *testing.T) {
|
||||
w := env.doJSON(t, http.MethodGet, "/api/v1/config/load", nil, obsAccess)
|
||||
require.Equal(t, http.StatusForbidden, w.Code)
|
||||
assertGoldenJSON(t, "2026-07-28/error_forbidden.json", w.Body.Bytes())
|
||||
})
|
||||
|
||||
t.Run("error_invalid_json", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/user/load", bytes.NewReader([]byte(`{not-json`)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+obsAccess)
|
||||
w := httptest.NewRecorder()
|
||||
env.router.ServeHTTP(w, req)
|
||||
require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String())
|
||||
assertGoldenJSON(t, "2026-07-28/error_invalid_json.json", w.Body.Bytes())
|
||||
})
|
||||
|
||||
t.Run("error_invalid_version", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/config/load", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+adminAccess)
|
||||
req.Header.Set(headerAPIVersion, "1.2026-07-28")
|
||||
w := httptest.NewRecorder()
|
||||
env.router.ServeHTTP(w, req)
|
||||
require.Equal(t, http.StatusBadRequest, w.Code)
|
||||
assertGoldenJSON(t, "2026-07-28/error_invalid_version.json", w.Body.Bytes())
|
||||
})
|
||||
|
||||
t.Run("error_unsupported_version", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/config/load", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+adminAccess)
|
||||
req.Header.Set(headerAPIVersion, "2020-01-01")
|
||||
w := httptest.NewRecorder()
|
||||
env.router.ServeHTTP(w, req)
|
||||
require.Equal(t, http.StatusBadRequest, w.Code)
|
||||
assertGoldenJSON(t, "2026-07-28/error_unsupported_version.json", w.Body.Bytes())
|
||||
})
|
||||
|
||||
t.Run("config_update_ok", func(t *testing.T) {
|
||||
w := env.doJSON(t, http.MethodPost, "/api/v1/config/update", map[string]any{
|
||||
"key_value_pairs": []map[string]string{
|
||||
{"key": "WELCOME_MESSAGE", "value": "hello golden"},
|
||||
},
|
||||
}, adminAccess)
|
||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
||||
assertGoldenJSON(t, "2026-07-28/config_update_ok.json", w.Body.Bytes())
|
||||
})
|
||||
|
||||
t.Run("user_load_self", func(t *testing.T) {
|
||||
w := env.doJSON(t, http.MethodPost, "/api/v1/user/load", map[string]any{
|
||||
"cid": env.observer.CID,
|
||||
}, obsAccess)
|
||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
||||
|
||||
// Rewrite dynamic CID to 0 for golden compare.
|
||||
var envelope map[string]any
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &envelope))
|
||||
dataMap, ok := envelope["data"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
dataMap["cid"] = float64(0) // JSON numbers → float64
|
||||
rewritten, err := json.Marshal(envelope)
|
||||
require.NoError(t, err)
|
||||
assertGoldenJSON(t, "2026-07-28/user_load_self.json", rewritten)
|
||||
})
|
||||
}
|
||||
|
||||
// assertGoldenJSON compares got JSON to a fixture under testdata/api_v1/.
|
||||
// Comparison is semantic (via json.Unmarshal + remarshal) so key order is ignored.
|
||||
func assertGoldenJSON(t *testing.T, rel string, got []byte) {
|
||||
t.Helper()
|
||||
path := filepath.Join("testdata", "api_v1", filepath.FromSlash(rel))
|
||||
wantBytes, err := os.ReadFile(path)
|
||||
require.NoError(t, err, "read golden %s", path)
|
||||
|
||||
var wantAny, gotAny any
|
||||
require.NoError(t, json.Unmarshal(wantBytes, &wantAny), "parse golden %s", path)
|
||||
require.NoError(t, json.Unmarshal(got, &gotAny), "parse response: %s", string(got))
|
||||
|
||||
wantNorm, err := json.Marshal(wantAny)
|
||||
require.NoError(t, err)
|
||||
gotNorm, err := json.Marshal(gotAny)
|
||||
require.NoError(t, err)
|
||||
|
||||
if !bytes.Equal(wantNorm, gotNorm) {
|
||||
t.Fatalf("golden mismatch for %s\n--- want ---\n%s\n--- got ---\n%s\n",
|
||||
rel, prettyJSON(wantNorm), prettyJSON(gotNorm))
|
||||
}
|
||||
}
|
||||
|
||||
func prettyJSON(b []byte) string {
|
||||
var buf bytes.Buffer
|
||||
if err := json.Indent(&buf, b, "", " "); err != nil {
|
||||
return string(b)
|
||||
}
|
||||
return strings.TrimSpace(buf.String())
|
||||
}
|
||||
523
internal/web/openapi/openapi.v1.yaml
Normal file
523
internal/web/openapi/openapi.v1.yaml
Normal file
@@ -0,0 +1,523 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: openfsd Operator REST API
|
||||
description: |
|
||||
JSON control surface for **operator automation** against an openfsd deployment.
|
||||
|
||||
**Blast radius:** Admin-minted API tokens currently carry Administrator-equivalent
|
||||
network rating claims. Treat tokens as full operator credentials until fine-grained
|
||||
scopes exist. Prefer short TTLs (max 90 days) and rotate via secret reset when needed.
|
||||
|
||||
**Versioning:** URL major is `/api/v1`. Rare breaking changes use the
|
||||
`OpenFSD-API-Version` date microversion header (`YYYY-MM-DD`). Production clients
|
||||
MUST send a supported pin. When omitted, the server defaults to `max_version` and
|
||||
sets `OpenFSD-API-Version-Defaulted: true`. Discovery (`GET /api/v1` and
|
||||
`GET /api/v1/versions`) and OpenAPI are version-agnostic (never reject on pin).
|
||||
|
||||
Envelope for JSON resources: `{ "version": "v1", "err": string|null, "data": … }`.
|
||||
Body `version` is the major only; microversion appears only in response headers.
|
||||
version: "1.0.0"
|
||||
x-openfsd-api-micro-min: "2026-07-28"
|
||||
x-openfsd-api-micro-max: "2026-07-28"
|
||||
|
||||
servers:
|
||||
- url: /api/v1
|
||||
description: Relative to the openfsd web listener
|
||||
|
||||
tags:
|
||||
- name: Discovery
|
||||
description: Version-agnostic discovery and machine-readable schema
|
||||
- name: Auth
|
||||
description: Password login / refresh (discouraged for automation; mint API tokens instead)
|
||||
- name: User
|
||||
description: User load/create/update (Stable)
|
||||
- name: Config
|
||||
description: Server configuration and API tokens (Stable; admin)
|
||||
- name: FSDConn
|
||||
description: Active FSD connection control (Stable)
|
||||
- name: Editor
|
||||
description: APT/AIR validate (Stable; Instructor1+)
|
||||
- name: Sweatbox
|
||||
description: Read-only PE proxies (raw; outside envelope goldens)
|
||||
- name: Data
|
||||
description: Public data feeds (outside microversion reject)
|
||||
|
||||
paths:
|
||||
/:
|
||||
get:
|
||||
tags: [Discovery]
|
||||
summary: API discovery
|
||||
operationId: getDiscovery
|
||||
x-openfsd-stability: stable
|
||||
security: []
|
||||
responses:
|
||||
"200":
|
||||
description: Discovery payload (same as /versions)
|
||||
headers:
|
||||
OpenFSD-API-Version:
|
||||
schema: { type: string }
|
||||
OpenFSD-API-Min-Version:
|
||||
schema: { type: string }
|
||||
OpenFSD-API-Max-Version:
|
||||
schema: { type: string }
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/APIV1EnvelopeDiscovery"
|
||||
|
||||
/versions:
|
||||
get:
|
||||
tags: [Discovery]
|
||||
summary: Supported microversions (same payload as GET /)
|
||||
operationId: getVersions
|
||||
x-openfsd-stability: stable
|
||||
security: []
|
||||
responses:
|
||||
"200":
|
||||
description: Discovery payload
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/APIV1EnvelopeDiscovery"
|
||||
|
||||
/openapi.json:
|
||||
get:
|
||||
tags: [Discovery]
|
||||
summary: OpenAPI 3 document (JSON)
|
||||
operationId: getOpenAPIJSON
|
||||
x-openfsd-stability: stable
|
||||
security: []
|
||||
responses:
|
||||
"200":
|
||||
description: OpenAPI document
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
|
||||
/openapi.yaml:
|
||||
get:
|
||||
tags: [Discovery]
|
||||
summary: OpenAPI 3 document (YAML source)
|
||||
operationId: getOpenAPIYAML
|
||||
x-openfsd-stability: stable
|
||||
security: []
|
||||
responses:
|
||||
"200":
|
||||
description: OpenAPI YAML
|
||||
content:
|
||||
application/yaml:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/auth/login:
|
||||
post:
|
||||
tags: [Auth]
|
||||
summary: Password login (not for production automation)
|
||||
operationId: postAuthLogin
|
||||
x-openfsd-stability: stable
|
||||
security: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [cid, password]
|
||||
properties:
|
||||
cid: { type: integer, minimum: 1 }
|
||||
password: { type: string }
|
||||
remember_me: { type: boolean }
|
||||
responses:
|
||||
"200":
|
||||
description: Access + refresh tokens
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/APIV1Envelope"
|
||||
|
||||
/auth/refresh:
|
||||
post:
|
||||
tags: [Auth]
|
||||
summary: Refresh access token (not for production automation)
|
||||
operationId: postAuthRefresh
|
||||
x-openfsd-stability: stable
|
||||
security: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [refresh_token]
|
||||
properties:
|
||||
refresh_token: { type: string }
|
||||
responses:
|
||||
"200":
|
||||
description: New access token
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/APIV1Envelope"
|
||||
|
||||
/fsd-jwt:
|
||||
post:
|
||||
tags: [Auth]
|
||||
summary: FSD JWT (VATSIM-shaped; outside envelope / microversion reject)
|
||||
operationId: postFsdJwt
|
||||
x-openfsd-stability: stable
|
||||
security: []
|
||||
responses:
|
||||
"200":
|
||||
description: VATSIM-shaped success body
|
||||
|
||||
/user/load:
|
||||
post:
|
||||
tags: [User]
|
||||
summary: Load user by CID
|
||||
operationId: postUserLoad
|
||||
x-openfsd-stability: stable
|
||||
security:
|
||||
- bearerAuth: []
|
||||
- cookieAuth: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/OpenFSDAPIVersion"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [cid]
|
||||
properties:
|
||||
cid: { type: integer, minimum: 1 }
|
||||
responses:
|
||||
"200":
|
||||
description: User record
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/APIV1Envelope"
|
||||
|
||||
/user/update:
|
||||
patch:
|
||||
tags: [User]
|
||||
summary: Update user (Supervisor+)
|
||||
operationId: patchUserUpdate
|
||||
x-openfsd-stability: stable
|
||||
security:
|
||||
- bearerAuth: []
|
||||
- cookieAuth: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/OpenFSDAPIVersion"
|
||||
responses:
|
||||
"200":
|
||||
description: Updated user
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/APIV1Envelope"
|
||||
|
||||
/user/create:
|
||||
post:
|
||||
tags: [User]
|
||||
summary: Create user (Supervisor+)
|
||||
operationId: postUserCreate
|
||||
x-openfsd-stability: stable
|
||||
security:
|
||||
- bearerAuth: []
|
||||
- cookieAuth: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/OpenFSDAPIVersion"
|
||||
responses:
|
||||
"201":
|
||||
description: Created user
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/APIV1Envelope"
|
||||
|
||||
/config/load:
|
||||
get:
|
||||
tags: [Config]
|
||||
summary: Load editable config keys (Administrator)
|
||||
operationId: getConfigLoad
|
||||
x-openfsd-stability: stable
|
||||
security:
|
||||
- bearerAuth: []
|
||||
- cookieAuth: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/OpenFSDAPIVersion"
|
||||
responses:
|
||||
"200":
|
||||
description: Key/value pairs
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/APIV1Envelope"
|
||||
|
||||
/config/update:
|
||||
post:
|
||||
tags: [Config]
|
||||
summary: Update config keys (Administrator)
|
||||
operationId: postConfigUpdate
|
||||
x-openfsd-stability: stable
|
||||
security:
|
||||
- bearerAuth: []
|
||||
- cookieAuth: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/OpenFSDAPIVersion"
|
||||
responses:
|
||||
"200":
|
||||
description: Success
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/APIV1Envelope"
|
||||
|
||||
/config/resetsecretkey:
|
||||
post:
|
||||
tags: [Config]
|
||||
summary: Rotate JWT secret (invalidates all tokens)
|
||||
operationId: postConfigResetSecretKey
|
||||
x-openfsd-stability: stable
|
||||
security:
|
||||
- bearerAuth: []
|
||||
- cookieAuth: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/OpenFSDAPIVersion"
|
||||
responses:
|
||||
"200":
|
||||
description: Secret rotated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/APIV1Envelope"
|
||||
|
||||
/config/createtoken:
|
||||
post:
|
||||
tags: [Config]
|
||||
summary: Mint long-lived admin API access token (max 90 days)
|
||||
description: |
|
||||
Returns a Bearer access JWT with Administrator rating claim plus recommended
|
||||
microversion fields. **Operator-automation blast radius:** treat as full admin
|
||||
credential. Production clients should send `OpenFSD-API-Version` equal to
|
||||
`recommended_api_version`.
|
||||
operationId: postConfigCreateToken
|
||||
x-openfsd-stability: stable
|
||||
security:
|
||||
- bearerAuth: []
|
||||
- cookieAuth: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/OpenFSDAPIVersion"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [expiry_date_time]
|
||||
properties:
|
||||
expiry_date_time:
|
||||
type: string
|
||||
format: date-time
|
||||
responses:
|
||||
"201":
|
||||
description: Token minted
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/APIV1EnvelopeCreateToken"
|
||||
|
||||
/fsdconn/kickuser:
|
||||
post:
|
||||
tags: [FSDConn]
|
||||
summary: Kick active connection by callsign (Supervisor+)
|
||||
operationId: postFsdconnKickuser
|
||||
x-openfsd-stability: stable
|
||||
security:
|
||||
- bearerAuth: []
|
||||
- cookieAuth: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/OpenFSDAPIVersion"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [callsign]
|
||||
properties:
|
||||
callsign: { type: string }
|
||||
responses:
|
||||
"200":
|
||||
description: Kicked
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/APIV1Envelope"
|
||||
|
||||
/editor/validate-apt:
|
||||
post:
|
||||
tags: [Editor]
|
||||
summary: Validate APT text (Instructor1+)
|
||||
operationId: postEditorValidateApt
|
||||
x-openfsd-stability: stable
|
||||
security:
|
||||
- bearerAuth: []
|
||||
- cookieAuth: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/OpenFSDAPIVersion"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [text]
|
||||
properties:
|
||||
text: { type: string }
|
||||
responses:
|
||||
"200":
|
||||
description: Soft validation result
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/APIV1Envelope"
|
||||
|
||||
/editor/validate-air:
|
||||
post:
|
||||
tags: [Editor]
|
||||
summary: Validate AIR text (Instructor1+)
|
||||
operationId: postEditorValidateAir
|
||||
x-openfsd-stability: stable
|
||||
security:
|
||||
- bearerAuth: []
|
||||
- cookieAuth: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/OpenFSDAPIVersion"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [text]
|
||||
properties:
|
||||
text: { type: string }
|
||||
responses:
|
||||
"200":
|
||||
description: Soft validation result
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/APIV1Envelope"
|
||||
|
||||
/sweatbox/state:
|
||||
get:
|
||||
tags: [Sweatbox]
|
||||
summary: Raw FSD sweatbox state (PE poll; non-envelope)
|
||||
operationId: getSweatboxState
|
||||
x-openfsd-stability: stable
|
||||
security:
|
||||
- bearerAuth: []
|
||||
- cookieAuth: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/OpenFSDAPIVersion"
|
||||
responses:
|
||||
"200":
|
||||
description: Raw FSD JSON body
|
||||
|
||||
/sweatbox/ops:
|
||||
get:
|
||||
tags: [Sweatbox]
|
||||
summary: Raw FSD sweatbox ops (PE poll; non-envelope)
|
||||
operationId: getSweatboxOps
|
||||
x-openfsd-stability: stable
|
||||
security:
|
||||
- bearerAuth: []
|
||||
- cookieAuth: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/OpenFSDAPIVersion"
|
||||
responses:
|
||||
"200":
|
||||
description: Raw FSD JSON body
|
||||
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
description: Access JWT (API token or short-lived access). No CSRF required.
|
||||
cookieAuth:
|
||||
type: apiKey
|
||||
in: cookie
|
||||
name: openfsd_session
|
||||
description: First-party session cookie; mutations require CSRF synchronizer.
|
||||
|
||||
parameters:
|
||||
OpenFSDAPIVersion:
|
||||
name: OpenFSD-API-Version
|
||||
in: header
|
||||
required: false
|
||||
description: |
|
||||
Date microversion pin (`YYYY-MM-DD` or `1.YYYYMMDD` or `latest`).
|
||||
Production clients MUST send a supported pin. Omitted → max + Defaulted header.
|
||||
schema:
|
||||
type: string
|
||||
example: "2026-07-28"
|
||||
|
||||
schemas:
|
||||
APIV1Envelope:
|
||||
type: object
|
||||
required: [version, err, data]
|
||||
properties:
|
||||
version:
|
||||
type: string
|
||||
enum: [v1]
|
||||
description: URL major only; microversion is header-only
|
||||
err:
|
||||
type: string
|
||||
nullable: true
|
||||
data:
|
||||
nullable: true
|
||||
|
||||
DiscoveryData:
|
||||
type: object
|
||||
required: [major, min_version, max_version, versions, header, default, openapi]
|
||||
properties:
|
||||
major: { type: string, example: v1 }
|
||||
min_version: { type: string, example: "2026-07-28" }
|
||||
max_version: { type: string, example: "2026-07-28" }
|
||||
versions:
|
||||
type: array
|
||||
items: { type: string }
|
||||
header: { type: string, example: OpenFSD-API-Version }
|
||||
default: { type: string, example: max }
|
||||
openapi: { type: string, example: /api/v1/openapi.json }
|
||||
|
||||
APIV1EnvelopeDiscovery:
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/APIV1Envelope"
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/DiscoveryData"
|
||||
|
||||
CreateTokenData:
|
||||
type: object
|
||||
required: [token, recommended_api_version, api_version_min, api_version_max]
|
||||
properties:
|
||||
token: { type: string }
|
||||
recommended_api_version: { type: string, example: "2026-07-28" }
|
||||
api_version_min: { type: string, example: "2026-07-28" }
|
||||
api_version_max: { type: string, example: "2026-07-28" }
|
||||
|
||||
APIV1EnvelopeCreateToken:
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/APIV1Envelope"
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/CreateTokenData"
|
||||
@@ -33,11 +33,20 @@ func (s *Server) setupRoutes() (*gin.Engine, error) {
|
||||
|
||||
// API groups — dual-accept Bearer | session cookie; CSRF when cookie-authenticated.
|
||||
apiV1Group := e.Group("/api/v1")
|
||||
apiV1Group.POST("/fsd-jwt", s.getFsdJwt)
|
||||
s.setupAuthRoutes(apiV1Group)
|
||||
|
||||
// Version-agnostic public discovery + OpenAPI (no JWT, no version-reject).
|
||||
apiV1Group.GET("", s.handleAPIDiscovery)
|
||||
apiV1Group.GET("/versions", s.handleAPIDiscovery)
|
||||
apiV1Group.GET("/openapi.json", s.handleOpenAPIJSON)
|
||||
apiV1Group.GET("/openapi.yaml", s.handleOpenAPIYAML)
|
||||
|
||||
apiV1Group.POST("/fsd-jwt", s.getFsdJwt) // outside microversion reject
|
||||
s.setupAuthRoutes(apiV1Group) // login/refresh: soft version headers only
|
||||
s.setupDataRoutes(apiV1Group) // never version-reject
|
||||
|
||||
// Dual-accept JSON resource groups (jwt + csrf + apiVersion).
|
||||
s.setupUserRoutes(apiV1Group)
|
||||
s.setupConfigRoutes(apiV1Group)
|
||||
s.setupDataRoutes(apiV1Group)
|
||||
s.setupFsdConnRoutes(apiV1Group)
|
||||
s.setupSweatboxAPIRoutes(apiV1Group)
|
||||
s.setupEditorAPIRoutes(apiV1Group)
|
||||
@@ -63,7 +72,7 @@ func (s *Server) setupAuthRoutes(parent *gin.RouterGroup) {
|
||||
|
||||
func (s *Server) setupUserRoutes(parent *gin.RouterGroup) {
|
||||
usersGroup := parent.Group("/user")
|
||||
usersGroup.Use(s.jwtBearerMiddleware, s.csrfIfCookieSession)
|
||||
s.useAPIV1Protected(usersGroup)
|
||||
usersGroup.POST("/load", s.getUserByCID)
|
||||
usersGroup.PATCH("/update", s.updateUser)
|
||||
usersGroup.POST("/create", s.createUser)
|
||||
@@ -71,7 +80,7 @@ func (s *Server) setupUserRoutes(parent *gin.RouterGroup) {
|
||||
|
||||
func (s *Server) setupConfigRoutes(parent *gin.RouterGroup) {
|
||||
configGroup := parent.Group("/config")
|
||||
configGroup.Use(s.jwtBearerMiddleware, s.csrfIfCookieSession)
|
||||
s.useAPIV1Protected(configGroup)
|
||||
configGroup.GET("/load", s.handleGetConfig)
|
||||
configGroup.POST("/update", s.handleUpdateConfig)
|
||||
configGroup.POST("/resetsecretkey", s.handleResetSecretKey)
|
||||
@@ -80,15 +89,16 @@ func (s *Server) setupConfigRoutes(parent *gin.RouterGroup) {
|
||||
|
||||
func (s *Server) setupFsdConnRoutes(parent *gin.RouterGroup) {
|
||||
fsdConnGroup := parent.Group("/fsdconn")
|
||||
fsdConnGroup.Use(s.jwtBearerMiddleware, s.csrfIfCookieSession)
|
||||
s.useAPIV1Protected(fsdConnGroup)
|
||||
fsdConnGroup.POST("/kickuser", s.handleKickActiveConnection)
|
||||
}
|
||||
|
||||
// setupSweatboxAPIRoutes mounts read-only PE proxies under /api/v1/sweatbox.
|
||||
// Mutations stay on HTML form POSTs (CSRF + PRG) — not on this JSON surface.
|
||||
// Raw GET bodies are outside envelope goldens; microversion middleware still applies.
|
||||
func (s *Server) setupSweatboxAPIRoutes(parent *gin.RouterGroup) {
|
||||
g := parent.Group("/sweatbox")
|
||||
g.Use(s.jwtBearerMiddleware, s.csrfIfCookieSession)
|
||||
s.useAPIV1Protected(g)
|
||||
g.GET("/state", s.handleAPISweatboxState)
|
||||
g.GET("/ops", s.handleAPISweatboxOps)
|
||||
}
|
||||
|
||||
5
internal/web/testdata/api_v1/2026-07-28/config_update_ok.json
vendored
Normal file
5
internal/web/testdata/api_v1/2026-07-28/config_update_ok.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"version": "v1",
|
||||
"err": null,
|
||||
"data": null
|
||||
}
|
||||
10
internal/web/testdata/api_v1/2026-07-28/createtoken_fields.json
vendored
Normal file
10
internal/web/testdata/api_v1/2026-07-28/createtoken_fields.json
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"version": "v1",
|
||||
"err": null,
|
||||
"data": {
|
||||
"token": "<redacted>",
|
||||
"recommended_api_version": "2026-07-28",
|
||||
"api_version_min": "2026-07-28",
|
||||
"api_version_max": "2026-07-28"
|
||||
}
|
||||
}
|
||||
13
internal/web/testdata/api_v1/2026-07-28/discovery.json
vendored
Normal file
13
internal/web/testdata/api_v1/2026-07-28/discovery.json
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "v1",
|
||||
"err": null,
|
||||
"data": {
|
||||
"major": "v1",
|
||||
"min_version": "2026-07-28",
|
||||
"max_version": "2026-07-28",
|
||||
"versions": ["2026-07-28"],
|
||||
"header": "OpenFSD-API-Version",
|
||||
"default": "max",
|
||||
"openapi": "/api/v1/openapi.json"
|
||||
}
|
||||
}
|
||||
5
internal/web/testdata/api_v1/2026-07-28/error_forbidden.json
vendored
Normal file
5
internal/web/testdata/api_v1/2026-07-28/error_forbidden.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"version": "v1",
|
||||
"err": "forbidden",
|
||||
"data": null
|
||||
}
|
||||
5
internal/web/testdata/api_v1/2026-07-28/error_invalid_json.json
vendored
Normal file
5
internal/web/testdata/api_v1/2026-07-28/error_invalid_json.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"version": "v1",
|
||||
"err": "invalid JSON body",
|
||||
"data": null
|
||||
}
|
||||
5
internal/web/testdata/api_v1/2026-07-28/error_invalid_version.json
vendored
Normal file
5
internal/web/testdata/api_v1/2026-07-28/error_invalid_version.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"version": "v1",
|
||||
"err": "invalid OpenFSD-API-Version",
|
||||
"data": null
|
||||
}
|
||||
5
internal/web/testdata/api_v1/2026-07-28/error_unauthorized.json
vendored
Normal file
5
internal/web/testdata/api_v1/2026-07-28/error_unauthorized.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"version": "v1",
|
||||
"err": "unauthorized",
|
||||
"data": null
|
||||
}
|
||||
5
internal/web/testdata/api_v1/2026-07-28/error_unsupported_version.json
vendored
Normal file
5
internal/web/testdata/api_v1/2026-07-28/error_unsupported_version.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"version": "v1",
|
||||
"err": "unsupported OpenFSD-API-Version \"2020-01-01\"; min=2026-07-28 max=2026-07-28",
|
||||
"data": null
|
||||
}
|
||||
11
internal/web/testdata/api_v1/2026-07-28/user_load_self.json
vendored
Normal file
11
internal/web/testdata/api_v1/2026-07-28/user_load_self.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "v1",
|
||||
"err": null,
|
||||
"data": {
|
||||
"cid": 0,
|
||||
"first_name": "Obs",
|
||||
"last_name": "Server",
|
||||
"network_rating": 1,
|
||||
"pilot_rating": 0
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user