Skip to content

OpenAPI Spec Authoring — Conventions and Pitfalls

The Katana client's source of truth is docs/katana-openapi.yaml. Two generator passes turn the spec into Python:

  1. scripts/regenerate_client.py — emits the attrs models, API methods, and client.py under katana_public_api_client/.
  2. scripts/generate_pydantic_models.py — emits the pydantic mirrors (and the sibling Cached<Name> cache tables used by MCP).

A spec edit is therefore not just a doc change — it directly shapes the public client surface and the cache schema. The rules below come from real failures where spec choices broke downstream consumers.

Before editing the spec, audit upstream drift via the workflow in docs/upstream-specs/README.md: poe refresh-upstream-specpoe audit-specpoe validate-response-examplespoe validate-examples.


The spec is OpenAPI 3.1 — use 3.1 conventions

docs/katana-openapi.yaml declares openapi: 3.1.0. Use 3.1 features rather than 3.0 work-arounds.

$ref siblings are legal in 3.1. Attach property metadata (especially description) directly alongside $ref, not wrapped in allOf: [{$ref: ...}] (the 3.0 idiom — still legal but unnecessary; the spec has a few legacy cases).

Use allOf only for real composition (combining a $ref with additional properties), not as a description-attacher.


Property descriptions belong at the use-site, not the schema-definition site

When a property references a shared schema via $ref, put the property's description as a sibling of the $ref so the description describes the role of this field on this object. The shared schema's own description should describe the type/enum's general meaning.

The two serve different audiences:

  • Schema-definition describes what the type is.
  • Use-site describes what the field's value means in context.

Example: ManufacturingOrder.status references ManufacturingOrderStatus and adds "Current production status of the manufacturing order"; the schema itself just says "Status of a manufacturing order."

The pydantic generator only emits Annotated[..., Field(description=...)] when the description is at the use-site, so use-site descriptions are also what surfaces in the generated client's IDE hovertext and generated docs. A bare $ref drops the description from generated pydantic — avoid except when the schema's own description is enough context for every caller (rare).


List responses must use a ListResponse schema with a data array property

Katana wraps every GET list endpoint in {"data": [...]}. If the OpenAPI spec defines a 200 response as type: array, the generated parser iterates response.json() directly — when the API returns the dict wrapper, iteration yields keys (strings) and Model.from_dict("data") raises:

ValueError: dictionary update sequence element #0 has length 1; 2 is required

Always define a proper MyListResponse schema (type: object, properties.data: {type: array, items: {$ref: ...}}) and reference it from the operation. The only documented exception is /user_info, which returns a flat object, not wrapped.

Test fixtures and mocks must also honor this — never put a raw array in a list mock.


Generator/schema edits must commit the regen in the same PR

Whenever you edit a generator script (scripts/generate_pydantic_models.py, scripts/regenerate_client.py) or the OpenAPI spec (docs/katana-openapi.yaml), run the regen, run uv run poe check (or at minimum agent-check + uv run poe test), and commit the regenerated output in the same PR. The input and its output stay locked together at every commit so the cause-and-effect chain is reviewable.

  • Pushing a generator/spec change without its regen leaves CI green-but-stale until the next time someone runs the generator.
  • Pushing regen output without the input change drifts in the other direction.

Note the generated-file impact in the PR description (e.g., "byte-identical except X" or list affected files).

Breaking-change marker

When the regen drops a previously-public class (e.g., a StrEnum deduped into a sibling) or narrows a field's type, the commit must use the breaking-change marker (feat(client)!: / fix(client)!:) with a BREAKING CHANGE: footer naming the affected symbol — see .github/agents/guides/shared/COMMIT_STANDARDS.md "Schema and Generator Changes" for the full rule.


Real names and emails from live API responses must never enter the repo

When testing against the live Katana API and incorporating response data into the spec, examples, or test fixtures, replace real names/emails with generic placeholders (Jane Doe, jane.doe@example.com, etc.). Privacy concern — real user data from production accounts should not be committed.


POST create endpoints return 200, not 201

Katana's convention across virtually every create endpoint is to return HTTP 200 on success — not the REST-orthodox 201. Authoring or copy-pasting "201": for a new post: block is a recurring footgun: the generated _parse_response only handles the documented status code, so when Katana actually returns 200 the parser falls through to "unknown status", leaves response.parsed = None, and unwrap_as raises UnexpectedResponseeven though the mutation landed server-side. The bug looks like a failure to the caller and invites a destructive retry.

Verified live (make_test_client() probe, 2026-05-27 and 2026-05-28):

  • POST /sales_order_fulfillments → 200
  • POST /stock_transfers → 200
  • POST /sales_return_rows → 200
  • POST /inventory_reorder_points → 200
  • POST /outsourced_purchase_order_recipe_rows → 200

Pinned by tests/test_openapi_specification.py::test_create_endpoint_success_status_codes and exercised end-to-end by tests/test_create_endpoint_regression.py.

The full sweep is complete — every POST create endpoint in the spec now declares 200. A baseline survey at the time of the sweep found these five outliers and no others.

If you genuinely encounter a Katana create endpoint that returns 201 (none confirmed to date), accept both by declaring "200" and "201" in the same responses: map pointing at the same schema — this future-proofs against Katana flipping the status code and is what the generator handles cleanly.


Two custom-fields surfaces coexist — never unify them

Katana exposes two unrelated custom-fields mechanisms. They look similar but have different wire shapes, different configuration endpoints, and different key semantics. Keep them separate in the spec; do not "DRY" one into the other.

Legacy (items) New (sales orders)
Entities Variant / Product / Material / Service SalesOrder / SalesOrderRow
Wire shape [{field_name, field_value}] array {<uuid>: value} dict
Configured via /custom_fields_collections /custom_field_definitions
Key configured field name definition id (UUID)
Canonical schema CustomFieldValue CustomFieldDefinition

The array surface is modelled by CustomFieldValue and the dict surface by CustomFieldDefinition + the CustomFieldOptions choice schemas. The dict values are typed by the definition's field_type (string / number / boolean / YYYY-MM-DD / URL string / integer choice id for singleSelect). entity_type is intentionally narrowed to the two live values — add a new entity type only once the live API accepts it. Katana has not migrated items/variants to the dict shape; if/when it does, that is a deliberate, separately-tracked spec change, not a silent unification here.

Sales-order custom-field search paths use snake_case custom_fields.<uuid> in where/order (matching the request/response body), per Katana's live /sales_orders/search schema. Pre-GA partner docs showed a camelCase customFields.<id> asymmetry, but the shipped API uses snake_case — verified live against the test tenant (make_test_client() probe, 2026-06-02): custom_fields.<uuid> passes where validation, while customFields.<uuid> is rejected with the same additionalProperties / "unexpected property" 422 as any unknown field. Do not reintroduce the camelCase form.


Fix bugs at the client/generator layer when the root cause lives there

The Katana client (katana_public_api_client) is a published, standalone package. Third-party Python users hit the same bugs we hit in MCP. When a bug surfaces in katana_mcp_server/.../typed_cache/sync.py, in a foundation tool, or in a helper but originates in generated client code (attrs, pydantic, from_attrs, Cached* schemas), apply the fix to the generator or spec — not the consumer.

Test: "would a standalone client user hit this bug?" If yes, fix it in the client.

Examples:

  • Pydantic*.from_attrs raising on {} from Katana → fix from_attrs codegen, not _attrs_*_to_cached.
  • Column(JSON) failing to serialize a pydantic instance → fix inject_json_columns in scripts/generate_pydantic_models.py, not sync.py.
  • Missing enum value → patch spec + regenerate, not enum-tolerant deserialization downstream.