Skip to content

API: generate /api/state documentation from UI types#31530

Merged
andig merged 15 commits into
masterfrom
feat/state-api-docs
Jul 23, 2026
Merged

API: generate /api/state documentation from UI types#31530
andig merged 15 commits into
masterfrom
feat/state-api-docs

Conversation

@naltatis

@naltatis naltatis commented Jul 6, 2026

Copy link
Copy Markdown
Member

pairs with evcc-io/docs#746

The /api/state response was previously undocumented. This PR makes the UI's TypeScript types the single source of truth: JSDoc-annotated interfaces are compiled into OpenAPI schemas and spliced into openapi.yaml as part of npm run build. Since REST, websocket and MQTT publish the same data, MQTT topic documentation can be generated from the same source later.

  • document all /api/state fields with descriptions, formats and examples
  • add missing State and Loadpoint fields (green share, tariff details, network, charger features)
  • schema generation via npm run openapi, wired into npm run build, output committed
  • payload validation for development: npm run openapi -- validate <file|url>
  • e2e smoke test boots evcc with the demo config and validates /api/state against the schema
  • fix /api/state response schema (no result wrapper) and remove dead externalDocs link
  • centralize types in types/evcc.ts; plan, rate and mode schemas are generated from the same source

@naltatis naltatis added the infrastructure Basic functionality label Jul 6, 2026
@naltatis
naltatis requested a review from Maschga July 6, 2026 12:18
@Maschga

Maschga commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

LGTM!
I wonder if we should add a test to validate the openapi files.
For evcc-io/docs#746, it would be great if we could also extract the information from getters/setters directly from the codebase. But that's probably a different PR.

@naltatis

naltatis commented Jul 7, 2026

Copy link
Copy Markdown
Member Author

For evcc-io/docs#746, it would be great if we could also extract the information from getters/setters directly from the codebase. But that's probably a different PR.

yes I deliberately focused on openapi first. MQTT docs can then be built on-top of this.

@naltatis
naltatis marked this pull request as ready for review July 7, 2026 18:32

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 1 security issue, 3 other issues, and left some high level feedback:

Security issues:

  • BlueOak-1.0.0: Open-source license could not be identified (link)

General comments:

  • Making Vehicle.name optional while still using it as a key in places (e.g. Record<string, Vehicle> in State and UI select options now falling back to an empty string) introduces ambiguity about whether name is guaranteed to be present; consider either keeping it required for configured vehicles or splitting out a separate type for guest/unnamed vehicles to keep the API contract clearer.
  • The schema coverage checker in scripts/state-schema/validate.ts relies on a hard‑coded IGNORE list for experimental or intentionally undocumented fields; to avoid this list becoming stale, consider deriving the ignore set from schema annotations (e.g. a custom JSDoc tag similar to @internal) so that experimental fields are skipped based on the source types instead of paths duplicated in code.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- Making `Vehicle.name` optional while still using it as a key in places (e.g. `Record<string, Vehicle>` in `State` and UI select options now falling back to an empty string) introduces ambiguity about whether `name` is guaranteed to be present; consider either keeping it required for configured vehicles or splitting out a separate type for guest/unnamed vehicles to keep the API contract clearer.
- The schema coverage checker in `scripts/state-schema/validate.ts` relies on a hard‑coded `IGNORE` list for experimental or intentionally undocumented fields; to avoid this list becoming stale, consider deriving the ignore set from schema annotations (e.g. a custom JSDoc tag similar to `@internal`) so that experimental fields are skipped based on the source types instead of paths duplicated in code.

## Individual Comments

### Comment 1
<location path="scripts/state-schema/validate.ts" line_range="12-16" />
<code_context>
+
+type AnySchema = any;
+
+function resolve(schema: AnySchema, schemas: Record<string, AnySchema>): AnySchema {
+  if (typeof schema?.$ref === "string") {
+    return resolve(schemas[schema.$ref.split("/").pop()!], schemas);
+  }
+  return schema ?? {};
+}
+
</code_context>
<issue_to_address>
**issue (bug_risk):** Silently resolving unknown $refs to an empty schema can hide specification/implementation mismatches.

Right now a `$ref` that doesn't match any key in `schemas` ends up as `{}` via the `schema ?? {}` fallback, so bad refs effectively become unconstrained schemas and go unnoticed. Instead, consider explicitly handling the `schemas[name]` lookup: if it's missing, throw or record a clear error so invalid refs in `openapi.yaml` or generated schemas are surfaced early.
</issue_to_address>

### Comment 2
<location path="scripts/state-schema/schemas.ts" line_range="43" />
<code_context>
+  return generator.createSchema(ROOT);
+}
+
+type AnySchema = any;
+
+function walk(node: any, visit: (schema: AnySchema) => void): void {
</code_context>
<issue_to_address>
**issue (complexity):** Consider simplifying the schema-processing code by introducing a minimal typed schema node, a shared visitor-based traversal, centralized $ref utilities, a combined strip+reachability pass, and extracting key-ordering into a dedicated formatter module.

You can keep the current behavior but simplify the “mini‑framework” by introducing a few small abstractions and merging some passes.

### 1. Replace `AnySchema = any` with a minimal structural type

This documents intent and makes traversals/ref handling safer without fully typing `ts-json-schema-generator` output:

```ts
interface JsonSchemaNode {
  $ref?: string;
  type?: string | string[];
  properties?: Record<string, JsonSchemaNode>;
  items?: JsonSchemaNode;
  required?: string[];
  anyOf?: JsonSchemaNode[];
  allOf?: JsonSchemaNode[];
  examples?: unknown[];
  example?: unknown;
  nullable?: boolean;
  internal?: boolean;
  // allow extra generator-specific fields
  [key: string]: any;
}

type AnySchema = JsonSchemaNode;
```

Use `JsonSchemaNode` everywhere instead of `any` (for `walk`, `stripInternal`, `normalizeNullables`, `rewriteRefs`, etc).

### 2. Introduce a shared visitor instead of bespoke traversals

You can unify `walk`, `stripInternal`, `collectRefs`, `normalizeNullables`, and `rewriteRefs` around a single, typed visitor that knows the basic schema shape:

```ts
type SchemaVisitor = (node: JsonSchemaNode, ctx: { parent?: JsonSchemaNode }) => void;

function visitSchema(node: JsonSchemaNode | JsonSchemaNode[] | undefined, visit: SchemaVisitor, parent?: JsonSchemaNode): void {
  if (!node) return;

  if (Array.isArray(node)) {
    for (const child of node) visitSchema(child, visit, parent);
    return;
  }

  visit(node, { parent });

  if (node.properties) {
    for (const child of Object.values(node.properties)) {
      visitSchema(child, visit, node);
    }
  }
  if (node.items) visitSchema(node.items, visit, node);
  if (node.anyOf) visitSchema(node.anyOf, visit, node);
  if (node.allOf) visitSchema(node.allOf, visit, node);
}
```

Then helpers become simple visitors instead of each defining its own traversal:

```ts
function stripInternal(root: JsonSchemaNode): void {
  visitSchema(root, (node) => {
    if (!node.properties) return;

    for (const [key, prop] of Object.entries(node.properties)) {
      if (prop.internal === true) {
        delete node.properties[key];
        if (Array.isArray(node.required)) {
          node.required = node.required.filter((r) => r !== key);
          if (!node.required.length) delete node.required;
        }
      }
    }
  });
}
```

### 3. Centralize `$ref` handling

Avoid duplicating string manipulation and `decodeURIComponent` logic across `collectRefs`, `reachableDefs`, and `rewriteRefs`:

```ts
const REFS = {
  tsDefinitions: "#/definitions/",
  openapiComponents: "#/components/schemas/",
};

interface ParsedRef {
  raw: string;
  name: string;
}

function parseTsDefRef(ref: string): ParsedRef | null {
  if (!ref.startsWith(REFS.tsDefinitions)) return null;
  const encoded = ref.slice(REFS.tsDefinitions.length);
  return { raw: ref, name: decodeURIComponent(encoded) };
}

function makeComponentRef(name: string): string {
  return `${REFS.openapiComponents}${name}`;
}
```

Reuse this in both ref collection and rewriting:

```ts
function collectRefs(schema: JsonSchemaNode): Set<string> {
  const refs = new Set<string>();
  visitSchema(schema, (node) => {
    const parsed = node.$ref && parseTsDefRef(node.$ref);
    if (parsed) refs.add(parsed.name);
  });
  return refs;
}

function rewriteRefs(schema: JsonSchemaNode, finalNames: Map<string, string>): void {
  visitSchema(schema, (node) => {
    const parsed = node.$ref && parseTsDefRef(node.$ref);
    if (!parsed) return;
    const renamed = finalNames.get(parsed.name);
    if (!renamed) throw new Error(`unresolved $ref "${node.$ref}"`);
    node.$ref = makeComponentRef(renamed);
  });
}
```

### 4. Combine strip + reachability in one pass

You can avoid the separate `collectRefs` + `reachableDefs` passes by tracking seen `$ref`s while stripping `@internal`:

```ts
function stripInternalAndCollectRefs(root: JsonSchemaNode, defs: Record<string, JsonSchemaNode>): Record<string, JsonSchemaNode> {
  const reachable = new Set<string>();

  visitSchema(root, (node) => {
    // strip internals
    if (node.properties) {
      for (const [key, prop] of Object.entries(node.properties)) {
        if (prop.internal === true) {
          delete node.properties[key];
          if (Array.isArray(node.required)) {
            node.required = node.required.filter((r) => r !== key);
            if (!node.required.length) delete node.required;
          }
        }
      }
    }

    // collect refs
    const parsed = node.$ref && parseTsDefRef(node.$ref);
    if (parsed) reachable.add(parsed.name);
  });

  const keep: Record<string, JsonSchemaNode> = {};
  const queue = [...reachable];

  while (queue.length) {
    const name = queue.shift()!;
    if (keep[name] || !defs[name]) continue;
    const def = defs[name];
    keep[name] = def;
    visitSchema(def, (node) => {
      const parsed = node.$ref && parseTsDefRef(node.$ref);
      if (parsed && !reachable.has(parsed.name)) {
        reachable.add(parsed.name);
        queue.push(parsed.name);
      }
    });
  }

  return keep;
}
```

Then `buildSchemas` calls:

```ts
const defs = stripInternalAndCollectRefs(root, definitions as Record<string, JsonSchemaNode>);
```

instead of `stripInternal` + `reachableDefs`.

### 5. Move key ordering to a dedicated formatting utility

To keep generation logic focused, move `KEY_ORDER` and `orderKeys` into a small module and restrict ordering to the outer components unless recursive ordering is strictly necessary:

```ts
// schemaFormatting.ts
export const KEY_ORDER = [
  "$ref",
  "description",
  "type",
  "nullable",
  "enum",
  "format",
  "example",
  "items",
  "properties",
  "required",
  "additionalProperties",
  "anyOf",
  "allOf",
];

export function orderSchemaKeys(node: JsonSchemaNode): JsonSchemaNode {
  if (Array.isArray(node)) return node.map(orderSchemaKeys);
  if (!node || typeof node !== "object") return node;

  const keys = Object.keys(node).sort((a, b) => {
    const ia = KEY_ORDER.indexOf(a);
    const ib = KEY_ORDER.indexOf(b);
    return (ia === -1 ? KEY_ORDER.length : ia) - (ib === -1 ? KEY_ORDER.length : ib);
  });

  return Object.fromEntries(
    keys.map((key) => [
      key,
      key === "properties" && node.properties
        ? mapValues(node.properties, orderSchemaKeys)
        : orderSchemaKeys(node[key]),
    ])
  ) as JsonSchemaNode;
}
```

`buildSchemas` then calls:

```ts
const orderedDefs = Object.fromEntries(
  Object.entries(result).map(([name, schema]) => [name, orderSchemaKeys(schema)])
);
```

This keeps formatting concerns separate and makes the core pipeline (build → strip/normalize → rename/refs → output) easier to follow.

All of these changes preserve the current behavior but reduce self‑inflicted complexity by centralizing traversal, typing schema nodes minimally, and consolidating ref and reachability logic.
</issue_to_address>

### Comment 3
<location path="scripts/state-schema/validate.ts" line_range="20" />
<code_context>
+}
+
+// report payload keys that have no schema property, additionalProperties:true hides them from ajv
+function coverage(
+  schema: AnySchema,
+  data: any,
</code_context>
<issue_to_address>
**issue (complexity):** Consider refactoring path construction and schema resolution in `coverage` to centralize path handling and avoid repeated `$ref` lookups during traversal.

You can simplify the validator without changing behavior by tightening path handling and avoiding repeated `$ref` resolution inside `coverage`.

### 1. Centralize path building

Right now `coverage` manually concatenates strings like `$.foo.bar` and you duplicate that format in `IGNORE`. A small helper makes this safer and easier to maintain:

```ts
function pushProp(path: string, key: string): string {
  return path === "$" ? `${path}.${key}` : `${path}.${key}`;
}

function pushArray(path: string): string {
  return `${path}[]`;
}
```

Then use it in `coverage`:

```ts
if (Array.isArray(data)) {
  if (s.items) data.forEach((item) => coverage(s.items, item, pushArray(path), schemas, report));
  return;
}

if (data !== null && typeof data === "object") {
  if (!s.properties) return;
  for (const key of Object.keys(data)) {
    const childPath = pushProp(path, key);
    if (s.properties[key]) {
      coverage(s.properties[key], data[key], childPath, schemas, report);
    } else if (!IGNORE.has(childPath)) {
      report.add(childPath);
    }
  }
}
```

This removes hand‑built path strings and makes it easier to evolve the path format or ignore list later.

### 2. Avoid repeated `$ref` resolution in `coverage`

`coverage` currently calls `resolve` on every recursion, which does a `split("/").pop()` each time. You can resolve once per call and pass the resolved schema down:

```ts
function resolve(schema: AnySchema, schemas: Record<string, AnySchema>): AnySchema {
  let current = schema;
  while (typeof current?.$ref === "string") {
    current = schemas[current.$ref.split("/").pop()!];
  }
  return current ?? {};
}

function coverage(
  schema: AnySchema,
  data: any,
  path: string,
  schemas: Record<string, AnySchema>,
  report: Set<string>
): void {
  const s = resolve(schema, schemas);

  if (s.anyOf) {
    if (data === null) return;
    const branch = s.anyOf.find((b: AnySchema) => resolve(b, schemas).type !== "null");
    if (branch) coverage(branch, data, path, schemas, report);
    return;
  }

  if (Array.isArray(data)) {
    if (s.items) data.forEach((item) => coverage(s.items, item, pushArray(path), schemas, report));
    return;
  }

  if (data !== null && typeof data === "object") {
    if (!s.properties) return;
    for (const key of Object.keys(data)) {
      const childPath = pushProp(path, key);
      if (s.properties[key]) {
        coverage(s.properties[key], data[key], childPath, schemas, report);
      } else if (!IGNORE.has(childPath)) {
        report.add(childPath);
      }
    }
  }
}
```

This keeps all current behavior but reduces per‑node work and makes the traversal logic clearer.
</issue_to_address>

### Comment 4
<location path="package-lock.json" line_range="12693-12710" />
<code_context>

</code_context>
<issue_to_address>
**security (license/glob):** BlueOak-1.0.0: Open-source license could not be identified

The obligations of the `BlueOak-1.0.0` license for this code could not be determined automatically. Unknown licenses may carry obligations or restrictions and should be reviewed manually to ensure compliance

*Source: trivy*
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread scripts/state-schema/validate.ts
Comment thread scripts/state-schema/schemas.ts
Comment thread scripts/state-schema/validate.ts
Comment thread package-lock.json
@andig

andig commented Jul 10, 2026

Copy link
Copy Markdown
Member

Ich bin nicht per se gegen diesen PR. ABER: die openapi.yaml suggeriert, dass dies eine API wäre (und ein Compatibility Promise hätte!). Dem ist nicht so- das ist hier ausschliesslich ein Abfallprodukt das jederzeit auseinander! Sollen wir das vllt. mindestens ein eine zweite stateapi.yaml auslagern? Die explizit unstable ist?

@Maschga

Maschga commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

Oder aber diese Information fügen wir bei dem Endpoint in der Beschreibung hinzu, soetwas wie: PLEASE NOTE: This endpoint is subject to change.

@naltatis

Copy link
Copy Markdown
Member Author

Dem ist nicht so- das ist hier ausschliesslich ein Abfallprodukt das jederzeit auseinander!

Darauf bauen ja fest alle lesenden Integrationen (HA, ...) auf. Daher würde ich die nicht als Abfallprodukt beschreiben.

Sollen wir das vllt. mindestens ein eine zweite stateapi.yaml auslagern?

Klare Kennzeichnung, dass wir hier nichts garantieren bzw. das sich das ändern kann finde ich gut. Können wir perspektivisch auch pro Feld bzw. Teilbaum (annotation) machen. Einige Teile sind ja stabiler als andere. Spätestens wenn wir uns 1.x.x trauen werden Nutzer das ja erwarten.

Eine separate openapi-Spec würde ich nicht machen weil wir dann das ganze Tooling (docs gen, mcp server, ...) verdoppeln müssen. Wir können es intern aber zerlegen, sodass der auto-generierte Part in einer eigenen Datei landet. Ich vermute, dass es dafür in der Spec einen Mechanismus gibt.

@naltatis

Copy link
Copy Markdown
Member Author

Extracted the generated schemas into dedicated openapi.state.yaml file. Having the OpenAPI spec split across different files is generally spec-conform but not supported by all libraries/clients. The openapi.json for our MCP implementation is a reassembled file. Same for the yaml we push to docs on release.

I've added the "structure may change" disclaimer to the state endpoint description.

@andig

andig commented Jul 13, 2026

Copy link
Copy Markdown
Member

Merge conflicts :O

@naltatis

Copy link
Copy Markdown
Member Author

resolve in progress ... ⏳

naltatis and others added 6 commits July 13, 2026 15:10
# Conflicts:
#	assets/js/components/Forecast/ValueDetails.vue
#	assets/js/types/evcc.ts
#	server/mcp/openapi.json
#	server/mcp/openapi.md
#	server/openapi.yaml
heat pump loadpoints publish session/plan/effective fields only after the
first control cycles. the default 5s expect.poll timeout raced this cold
start, failing on ci where each retry restarts evcc.
# Conflicts:
#	assets/js/components/ChargingPlans/ChargingPlanModal.vue
@andig

andig commented Jul 23, 2026

Copy link
Copy Markdown
Member

@copilot resolve the merge conflicts in this pull request

# Conflicts:
#	server/mcp/openapi.json
#	server/openapi.yaml

Copilot AI commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

@copilot resolve the merge conflicts in this pull request

Resolved. The conflicts were in server/openapi.yaml and server/mcp/openapi.json.

For openapi.yaml: adopted master's fix from #31989 (remove result wrappers from response schemas) while keeping the ./openapi.state.yaml#/... references for generated schemas (ChargeMode, StaticEnergyPlan, PlanStrategy, RepeatingPlan, State).

For openapi.json: regenerated via npm run openapi since it's a bundled artifact.

Copilot finished work on behalf of andig July 23, 2026 08:56
Copilot AI requested a review from andig July 23, 2026 08:56
@andig
andig merged commit 542a01f into master Jul 23, 2026
10 of 11 checks passed
@andig
andig deleted the feat/state-api-docs branch July 23, 2026 10:03
@StefanSchoof

Copy link
Copy Markdown
Contributor

This PR readds the wrong result wrapper, which was fixed by #31989

@andig

andig commented Jul 24, 2026

Copy link
Copy Markdown
Member

This PR was reverted in #32109 since it broker docker build in #32100. This fix from #32101 is not clean and had to be aborted. This one will need restructuring.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

infrastructure Basic functionality

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants