Harvest publishes an OpenAPI 3.1 document for the whole management API, so any language with an OpenAPI generator gets a typed client with no hand-written HTTP.

One source, three places it appears:

WhereWhat
GET {api_path}/openapi.jsonServed by harvest_api_router. Read-only, and behind no admin gate.
autumn-harvest-plugin/openapi.jsonCompact. Compiled into the crate and served verbatim, so the endpoint runs no transform and can never fail.
docs/openapi.jsonThe same document, pretty-printed, for offline codegen and review diffs.

All three come from docs/api-contract.json. The chain is contract to canonical route list to document, and a test pins every link. See Guarantees.

The crate carries its own copy because a published crate can package no file from outside its own directory. cargo xtask dev regenerate-openapi writes both copies from one transform, and a test fails when either drifts.

If your app already serves /openapi.json

It keeps it. The route is nest-relative, so HarvestPlugin::api("/api/harvest") puts this document at /api/harvest/openapi.json, and autumn-web's own generated document stays at the root. The two describe different surfaces: autumn-web's covers the routes your app declares with its route macros, and this one covers the management API. openapi_spec::the_route_does_not_take_the_application_root_path pins that.

Mounting the management router at the application root is the one case that collides. Axum then rejects the duplicate GET /openapi.json at startup.

Use the published TypeScript client

Each GitHub release after 0.6.0 attaches autumn-harvest-client, a client built from docs/openapi.json. You do not need a running Harvest to install it or to write code against it. Replace <version> with your server's version:

Shell
npm install https://github.com/autumn-foundation/autumn-harvest/releases/download/v<version>/autumn-harvest-client-<version>.tgz

The package version is the crate version. See clients/typescript for the API and for the routes whose responses have full types.

Generate a typed client in under ten minutes

The worked example lives in examples/typescript-client, which also says how to get a plugin running. With one serving http://localhost:3000/api/harvest:

Shell
cd examples/typescript-client
npm ci
npm run generate    # openapi-typescript reads the served document
npm run typecheck
npm start           # starts a workflow and polls its status

src/start-and-poll.ts calls POST /workflows/{workflow_name}/start and GET /workflows/{id} through openapi-fetch. It contains no URL string, no method string and no fetch call.

Other generators read the same document:

Shell
# Python
openapi-python-client generate --url http://localhost:3000/api/harvest/openapi.json

# Go, Java, and 50 other targets. Use 7.x or newer: earlier versions reject
# OpenAPI 3.1 outright.
openapi-generator generate -i docs/openapi.json -g go -o ./harvest-client

Node 20 or newer for the TypeScript path. The first cargo run -p quickstart compiles the workspace, which is the slow part; the client steps take seconds.

What the document carries

  • Paths relative to the mount point. servers[0].url is /api/harvest, the conventional prefix. Change it when HarvestPlugin::api mounts the router elsewhere.
  • operationId per operation, derived from the method and path (post_workflows_by_workflow_name_start). It is stable across regenerations, so a generated method name does not churn.
  • Parameters with explicit required flags, for path, query and header. A repeatable query key is an exploded array.
  • A request-body schema for every route that takes a body. A route that takes none declares none, so a generated client does not send an empty object.
  • x-harvest-read-only and x-harvest-route-class on every operation. Both mirror autumn_harvest::audit::CLASSIFIED_ROUTES, so tooling can separate reads from control-plane writes, and can see which routes are safe without any credential. The route category is also the operation tag.
  • x-harvest-idempotency, where the contract records an idempotency rule.
  • x-harvest-stream on every text/event-stream route. A blocking client that buffers a whole response would hang on such a route.
  • Response headers where the contract records them: X-Harvest-Execution-Id on the by-id family, Retry-After on the 204 a still-running /result returns.
  • Security schemes for the two credentials the management API accepts: HarvestBearerToken (scoped API tokens) and HarvestSessionCookie (the embedding application session). The top-level security list also carries an empty requirement, because enforcement is the embedder's choice. The public_safe routes are GET /health, GET /health/live, GET /health/ready and GET /openapi.json. They override it with security: [], which says positively that Harvest itself asks for no credential there. An embedder that wraps the router with HarvestPlugin::api_with_auth gates every route, the document included, so a generator then needs a credential to read it. See security-posture.md.

Limits worth knowing

  • Most response properties are open. The contract records field names, and sometimes a type, not full JSON Schemas. A generator therefore types most response properties as unknown / any. The core lifecycle routes are the exception: every field declares a type. The list is autumn_harvest_plugin::openapi::CORE_CLIENT_ROUTES. On those routes, a field that holds any JSON value, such as workflow input, carries x-harvest-any. A client sees it as unknown. Per-workflow message schemas are a different contract; see GET /workflows/registered/{name}/schema.
  • Streaming routes declare text/event-stream with a string body. The frame grammar is in the response description.
  • Conditional shapes (the partial-availability envelopes of issue #756) are described in prose on the response, not as a oneOf.
  • Error responses carry no schema. The contract records a description per error status, not a body shape.
  • No check reads the type of a hand-parsed query key. Every parameter declares a type, and the transform rejects one without it. The audit checks each Query<T> field against the contract. A key parsed from RawQuery has a type set by hand. No check compares that type with the parser.
  • Inline schemas, no $ref. Nothing is shared between operations, so openapi-generator names response models positionally.
  • GET /workflows/by-id/{workflow_name}/{workflow_id}/children declares workflow_name twice: once as the parent path parameter, once as a query filter on the child type. It is legal OpenAPI, but a generator that flattens parameters into one argument list may emit two arguments with one name.

Regenerate the artifacts

Shell
cargo xtask dev regenerate-openapi

Run it whenever docs/api-contract.json changes. CI fails otherwise.

Guarantees

Six checks hold the chain together:

  1. contract_regression::management_routes_match_contract fails when management_api_routes() and the contract disagree. That function is the canonical route list every registration surface is checked against.
  2. openapi_spec::document_covers_every_management_route_exactly fails when the document misses a mounted route, or invents one.
  3. openapi_spec::checked_in_artifacts_match_the_generated_document fails when either checked-in copy is stale, and openapi_spec::served_endpoint_returns_the_document fails when the endpoint serves anything else.
  4. The lint job validates both copies with openapi-spec-validator, and it runs on documentation-only pull requests too.
  5. cargo xtask audit openapi-response-coverage, also in lint, reads the handlers: every status a handler returns must be declared, every request-body field that is mandatory on the wire must be marked required, and every query key a hand-rolled parser matches must be documented. Every field serde accepts must be documented. The contract must mark a body required when the handler cannot run without it. Each Query<T> field must be documented with its type and required flag. The audit also reads a body parsed from raw Bytes. A type the audit cannot resolve fails the check.
  6. openapi_response_conformance drives each core lifecycle route against Postgres. It asserts the status of each response shape, and checks every body against the published schema. An undeclared key, a wrong type, an undeclared null, an array with no element type or a missing required key fails it. openapi_spec::core_client_routes_type_every_response_field fails when a core route loses a type.

Why the document is derived, not annotated

autumn-web builds an OpenAPI document from routes declared with its route macros, which attach ApiDoc metadata to each handler. harvest_api_router is a plain axum::Router, so its routes carry no such metadata. Annotating the handlers would also record less than the contract already holds: per-parameter required flags, per-route read-only class, idempotency rules, and error responses. The contract is therefore the input, and autumn-harvest-plugin/src/openapi.rs is the transform.