demoapi

package
v0.37.0 Latest Latest
Warning

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

Go to latest
Published: Oct 13, 2026 License: MIT Imports: 14 Imported by: 0

Documentation

Overview

Package demoapi is a fake API to build TUIs against: applications that can be listed, filtered, paged and sorted, and jobs that run against them and finish after real time has passed.

It is a fixture. It has no authentication, it will fail or stall a request on request, and the binary binds loopback on purpose. Do not deploy it.

Why it is a handler

New returns an http.Handler rather than running a server, which is what lets one fixture serve four uses:

srv := httptest.NewServer(demoapi.New(demoapi.Options{Seed: 7}))  // tests
client := demoapi.Client(demoapi.New(demoapi.Options{}))          // examples, no socket
go run ./demoapi/cmd/demoapi                                      // a terminal
import "github.com/jsdrews/tuilib/demoapi"                        // another project

A main cannot be shared. A handler can be shared four ways.

Why it has no dependencies

Standard library only, plus pkg/query from this module. The cost of a package here is not compile time — Go builds what is imported — it is go.mod: anything demoapi required, every consumer of tuilib would require. The day this needs a third-party import it should become a nested module.

Latency and failure are per-request

Every endpoint honours ?latency=, ?fail= and ?flaky=. Per-request rather than server-wide because the orderings worth testing are the asymmetric ones: a slow reply to an abandoned filter arriving after a fast reply to the current one is what source.Deliver's generation check exists for, and one knob for the whole server cannot express it.

GET /apps?q=eu&latency=900ms    // the abandoned query
GET /apps?q=euro                // what the user actually typed

Scenario knobs

Beyond latency and failure, the fixture can reproduce the behaviours a per-row activity indicator has to survive. Each is a query parameter on the request it applies to, or — where the effect has to outlive one request — an endpoint under /chaos/.

The shape follows Argo CD, because that is the API the activity feature was found wanting against. Sync is a comparison (Synced, OutOfSync) and never an activity; work in progress is Phase. The three ways a client learns an operation ended are all here, one per verb:

POST /apps/{id}/sync                   // fire-and-observe: 202 at once, Phase says Running
GET  /apps/{id}?refresh=normal         // blocking: held until reconciled, the app shows nothing
GET  /jobs/{job}                       // job handle: the id the 202 returned

GET  /apps/{id}?refresh=1&takes=5s     // how long the refresh holds the connection
GET  /apps?stale=2s                    // a replica that has not caught up
POST /apps/{id}/sync?reconcile=2s      // accepted now, reported later
POST /apps/{id}/sync?blackhole=1       // accepted, given an id, dropped
POST /apps/{id}/sync?say=Superseded    // a status no predicate was written for
POST /apps/{id}/sync?phases=a,b,c      // a status that moves while it works
POST /apps/{id}/sync?takes=800ms       // how long the work runs
GET  /jobs?status=running              // the busy set as its own collection
POST /chaos/down?for=5s                // every read 503s until it passes
POST /chaos/delete/{id}                // a row leaves the set
POST /chaos/spawn?id=X&busy=1          // a row arrives, already working

POST /logs-app/_search answers like Elasticsearch over a growing log index: the 10,000-hit window, search_after with a required tiebreaker, totals that stop counting, refresh, and documents ingested late. See es.go.

AWX job events

Under /api/v2/jobs/ the fixture answers like AWX's job endpoints — counter ranges, page mode capped at 200, stdout and outcome filters, a running job whose late-saved events leave holes and whose event processing trails its status. See awx.go.

Alternating ?stale= with a fresh read is how a read path that goes *backwards* is reproduced — busy, settled, busy — which no client-side generation check can repair, because neither reply overtook the other. Deleting an id and spawning it again is how key reuse is reproduced.

The world advances on the clock

A job started at T finishes at T+4s because four seconds passed, not because four requests arrived — so a curl in another terminal sees the same world a TUI does. Nothing runs in the background; each read applies whatever became due. Options.Now pins the clock for a test, and the same code path serves both.

Index

Constants

View Source
const (
	SyncSynced    = "Synced"
	SyncOutOfSync = "OutOfSync"
)

Sync states. There is no in-flight one: Argo's sync status is a comparison, not an activity, and work in progress is reported in Phase.

View Source
const (
	PhaseRunning   = "Running"
	PhaseSucceeded = "Succeeded"
	PhaseFailed    = "Failed"
)

Operation phases, as status.operationState.phase reports them.

View Source
const (
	JobRunning   = "running"
	JobSucceeded = "succeeded"
	JobFailed    = "failed"
)

Job statuses.

View Source
const DefaultApps = 5000

DefaultApps is enough rows that a windowed table is actually windowed.

View Source
const DefaultSchedule = 6 * time.Second

DefaultSchedule is how often the world starts work of its own.

View Source
const EnvBase = "TUILIB_DEMO_API"

EnvBase names the environment variable that points a client at a running demoapi instead of an in-process one.

Variables

View Source
var (
	ErrNoSuchApp = errors.New("no such application")
	ErrBusy      = errors.New("an operation is already running")
)

Errors launch reports. They exist so the handler can answer 404 and 409 distinctly, which is the whole of the conflict story: a client cannot avoid every race, so the server has to be the one that says no.

Functions

func Client

func Client(h http.Handler) *http.Client

Client returns an *http.Client that dispatches straight into h.

No port, no bind, no cleanup, and no flake when twenty examples start at once. A screen still builds an *http.Request and still parses JSON out of an *http.Response, so nothing about how it is written changes — which is the point: `task examples` needs no sidecar running, and the same screen works unaltered against a real server.

Streaming works. See inProcess.RoundTrip for why that took a pipe.

func New

func New(opts Options) http.Handler

New returns the fixture as an http.Handler.

Types

type App

type App struct {
	ID     string `json:"id"`
	Name   string `json:"name"`
	Region string `json:"region"`
	Sync   string `json:"sync"`
	Health string `json:"health"`

	// Phase is the last operation's phase: "" before any, Running while one
	// runs, then Succeeded or Failed until the next. It stays after the work
	// ends, as Argo's operationState does, so "not Running" is the settled
	// test rather than "empty".
	Phase string `json:"phase"`

	// ReconciledAt is when the server last compared desired and live state.
	// A refresh moves it; that is the only visible evidence a refresh
	// happened when it finds nothing new.
	ReconciledAt time.Time `json:"reconciledAt"`

	// Rev changes whenever a job against this app completes — a revision
	// whose contract is to move when the work does, the way finished_at or
	// resourceVersion does. Nothing in tuilib reads it today; it is on the
	// wire so a screen can, and so the "work that began and ended between two
	// polls" case has evidence a client could in principle notice.
	Rev int64 `json:"rev"`
	// contains filtered or unexported fields
}

App is one application, shaped the way Argo CD reports one.

Sync and Phase are separate fields because they are separate facts in Argo, and an earlier version of this fixture that merged them taught every client the wrong one. Sync is the comparison between desired and live state — Synced or OutOfSync, and nothing else. Phase is status.operationState.phase: what an operation against the app is doing right now. A sync in progress leaves Sync alone and sets Phase to Running; a client watching Sync for in-flight work sees nothing at all, which is exactly what a real Argo does.

type Job

type Job struct {
	ID       string    `json:"id"`
	App      string    `json:"app"`
	Kind     string    `json:"kind"`
	Status   string    `json:"status"`
	Started  time.Time `json:"started"`
	Finished time.Time `json:"finished,omitzero"`
	// contains filtered or unexported fields
}

Job is work running against an app.

type Options

type Options struct {
	// Seed makes the generated world and its scheduled events reproducible.
	// Zero seeds from the clock.
	Seed int64

	// Now is the clock the world advances on. Nil means time.Now. A test
	// passes a controllable one and drives transitions exactly.
	Now func() time.Time

	// Apps is how many applications to generate. Zero means DefaultApps.
	Apps int

	// Latency is a floor applied to every request, before any per-request
	// ?latency=. Zero means none.
	Latency time.Duration

	// Schedule is how often the world starts work nobody asked for. Zero means
	// DefaultSchedule.
	//
	// A demo wants this short: background work is the thing a screen with
	// BusyWhen exists to show, and a viewer who has to wait out two
	// intervals to see one concludes the feature does not work.
	Schedule time.Duration

	// Vocab is the set of phases scheduled work reports while running. Nil
	// means "Running" for everything, as Argo reports it, which is what a demo
	// wants; a list is how a screen is shown statuses it was not written
	// against — an unfamiliar phase, other casing, a counter, a wide rune.
	//
	// Per-request ?say= is the sharper tool and the one tests should use.
	// This exists so an interactive demo can be odd without a client asking.
	Vocab []string
}

Options configures the fixture.

type Target

type Target struct {
	Client *http.Client

	// Base has no trailing slash. For an in-process target it is a hostname
	// the transport never resolves, and exists only so request URLs are valid.
	Base string

	// Live reports whether this talks to a separate process. A screen that
	// wants to say so on its border can read it; nothing else behaves
	// differently.
	Live bool
}

Target is where a client should talk: a client, and the base URL to build request paths onto.

func From

func From(opts Options) Target

From returns a Target chosen by the environment.

With TUILIB_DEMO_API set, requests go over the network to that base URL and opts is ignored — the server already has a world, and a second one generated here would be a different set of applications answering none of the requests. Unset, it builds an in-process handler from opts and dispatches into it with no socket, which is what keeps `task examples` free of a sidecar.

The point of the live path is the workflow the wall-clock rule exists for: one world, a TUI watching it, and a curl poking it from another terminal.

func (Target) URL

func (t Target) URL(path string) string

URL joins a path onto the target's base.

Directories

Path Synopsis
cmd
demoapi command
Command demoapi runs the demo API on loopback.
Command demoapi runs the demo API on loopback.

Jump to

Keyboard shortcuts

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