API: generate /api/state documentation from UI types#31530
Conversation
…at/state-api-docs
|
LGTM! |
yes I deliberately focused on openapi first. MQTT docs can then be built on-top of this. |
There was a problem hiding this comment.
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.nameoptional while still using it as a key in places (e.g.Record<string, Vehicle>inStateand UI select options now falling back to an empty string) introduces ambiguity about whethernameis 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.tsrelies on a hard‑codedIGNORElist 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>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
|
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? |
|
Oder aber diese Information fügen wir bei dem Endpoint in der Beschreibung hinzu, soetwas wie: PLEASE NOTE: This endpoint is subject to change. |
Darauf bauen ja fest alle lesenden Integrationen (HA, ...) auf. Daher würde ich die nicht als Abfallprodukt beschreiben.
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 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. |
|
Extracted the generated schemas into dedicated I've added the "structure may change" disclaimer to the state endpoint description. |
|
Merge conflicts :O |
|
resolve in progress ... ⏳ |
# 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
|
@copilot resolve the merge conflicts in this pull request |
# Conflicts: # server/mcp/openapi.json # server/openapi.yaml
Resolved. The conflicts were in For For |
|
This PR readds the wrong result wrapper, which was fixed by #31989 |
pairs with evcc-io/docs#746
The
/api/stateresponse 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 intoopenapi.yamlas part ofnpm run build. Since REST, websocket and MQTT publish the same data, MQTT topic documentation can be generated from the same source later./api/statefields with descriptions, formats and examplesStateandLoadpointfields (green share, tariff details, network, charger features)npm run openapi, wired intonpm run build, output committednpm run openapi -- validate <file|url>/api/stateagainst the schema/api/stateresponse schema (noresultwrapper) and remove dead externalDocs linktypes/evcc.ts; plan, rate and mode schemas are generated from the same source