Skip to content

feat: bake openapi schemas into endpoint annotations#1315

Draft
leonardmq wants to merge 2 commits into
mainfrom
leonard/kil-591-add-request-schema-in-annotation
Draft

feat: bake openapi schemas into endpoint annotations#1315
leonardmq wants to merge 2 commits into
mainfrom
leonard/kil-591-add-request-schema-in-annotation

Conversation

@leonardmq
Copy link
Copy Markdown
Collaborator

@leonardmq leonardmq commented Apr 22, 2026

What does this PR do?

Add endpoint request schema into the endpoint annotations so we can validate client toolcalls on the backend.

Change is only in libs/server/kiln_server/utils/agent_checks/dump_annotations.py, the rest of the files are *.json annotations regenerated.

Checklists

  • Tests have been run locally and passed
  • New tests have been added to any work in /lib

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 22, 2026

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 9c5ed1e9-c7a4-4c38-abe2-ff87973ea491

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch leonard/kil-591-add-request-schema-in-annotation

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 22, 2026

📊 Coverage Report

Overall Coverage: 92%

Diff: origin/main...HEAD

  • libs/server/kiln_server/utils/agent_checks/dump_annotations.py (90.4%): Missing lines 55,85,115,155,159,200,204,208,287-288,291

Summary

  • Total: 115 lines
  • Missing: 11 lines
  • Coverage: 90%

Line-by-line

View line-by-line diff coverage

libs/server/kiln_server/utils/agent_checks/dump_annotations.py

Lines 51-59

  51 
  52 def _ref_name(ref: str) -> str | None:
  53     """Return the component-schema name for a local $ref, else None."""
  54     if not isinstance(ref, str) or not ref.startswith(_COMPONENT_REF_PREFIX):
! 55         return None
  56     return ref[len(_COMPONENT_REF_PREFIX) :]
  57 
  58 
  59 def _inline_schema(

Lines 81-89

  81         if "$ref" in node:
  82             ref = node["$ref"]
  83             name = _ref_name(ref)
  84             if name is None:
! 85                 raise _SchemaResolutionError(f"Unsupported $ref: {ref!r}")
  86             if name in resolving:
  87                 deferred_defs.setdefault(name, {})
  88                 return {"$ref": f"#/$defs/{name}"}
  89             if name not in components_schemas:

Lines 111-119

  111     inlined, defs_map = _inline_schema(schema, components_schemas)
  112     if not defs_map:
  113         return inlined
  114     if not isinstance(inlined, dict):
! 115         raise _SchemaResolutionError("Cannot attach $defs to non-object schema root")
  116     assert "$defs" not in inlined, "Unexpected pre-existing $defs in component schema"
  117     return {**inlined, "$defs": defs_map}
  118 

Lines 151-163

  151             "skipping request body schema"
  152         )
  153         return None
  154     if json_entry.media_type_schema is None:
! 155         logger.warning(
  156             f"application/json requestBody has no schema on {method.upper()} {path}; "
  157             "skipping request body schema"
  158         )
! 159         return None
  160     raw_schema = raw_operation["requestBody"]["content"]["application/json"]["schema"]
  161     inlined = _inline_with_defs(raw_schema, components_schemas)
  162     return {
  163         "required": request_body.required,

Lines 196-212

  196     # Operation-level parameters override path-item ones with the same (name, in).
  197     merged: dict[tuple[str, ParameterLocation], dict] = {}
  198     for typed, raw in zip(typed_params, raw_params):
  199         if isinstance(typed, Reference):
! 200             logger.warning(
  201                 f"Parameter $ref not supported on {method.upper()} {path} "
  202                 f"(parameter {typed.ref!r}); skipping"
  203             )
! 204             continue
  205         if typed.param_in not in _SUPPORTED_LOCATIONS:
  206             continue
  207         if not isinstance(raw, dict):
! 208             continue
  209         merged[(typed.name, typed.param_in)] = raw
  210 
  211     for (name, param_in), raw in merged.items():
  212         location = param_in.value

Lines 283-295

  283                     components_schemas,
  284                     method,
  285                     path,
  286                 )
! 287             except _SchemaResolutionError as e:
! 288                 logger.warning(
  289                     f"Failed to extract parameters on {method.upper()} {path}: {e}"
  290                 )
! 291                 parameters = {"path": {}, "query": {}}
  292 
  293             filename = normalize_endpoint_filename(method, path)
  294             filepath = os.path.join(target_folder, filename)
  295             with open(filepath, "w", encoding="utf-8") as f:


Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request enhances API endpoint annotations by adding request_body and parameters metadata derived from the OpenAPI specification. It implements a schema inlining process in dump_annotations.py that supports recursive models via local $defs and provides updated documentation and tests. The review identifies bugs in parameter merging for $ref objects and reference resolution within discriminator.mapping, while also suggesting an optimization for resolving cyclic models.

Comment on lines +167 to +181
merged: dict[tuple[str, str], dict] = {}
for param in path_item.get("parameters", []) or []:
if not isinstance(param, dict):
continue
name = param.get("name")
location = param.get("in")
if isinstance(name, str) and isinstance(location, str):
merged[(name, location)] = param
for param in operation.get("parameters", []) or []:
if not isinstance(param, dict):
continue
name = param.get("name")
location = param.get("in")
if isinstance(name, str) and isinstance(location, str):
merged[(name, location)] = param
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.

medium

The current logic for merging parameters from path_item and operation silently ignores parameters defined as $ref (e.g., {"$ref": "#/components/parameters/MyParam"}). This is because the loop checks for name and in keys, which are absent in a $ref object. Consequently, if an operation overrides a path-level parameter using a $ref, the path-level parameter will incorrectly persist in the merged dictionary. Additionally, the warning for unsupported parameter $ref on line 187 is unreachable because such objects never make it into the merged map.

Comment on lines +73 to +93
if "$ref" in node:
ref = node["$ref"]
name = _ref_name(ref)
if name is None:
raise _SchemaResolutionError(f"Unsupported $ref: {ref!r}")
if name in resolving:
deferred_defs.setdefault(name, {})
return {"$ref": f"#/$defs/{name}"}
if name not in components_schemas:
raise _SchemaResolutionError(
f"$ref {ref!r} not found in components.schemas"
)
resolving.add(name)
try:
inlined = recurse(components_schemas[name])
finally:
resolving.discard(name)
if name in deferred_defs:
deferred_defs[name] = inlined
return {"$ref": f"#/$defs/{name}"}
return inlined
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.

medium

The recurse function in _inline_schema only resolves $ref keys within dictionaries. However, OpenAPI's discriminator.mapping uses a dictionary where the values are strings representing refs (e.g., "mapping": {"type_a": "#/components/schemas/TypeA"}). These strings will not be resolved or updated by the current logic, leading to broken references in the generated annotation files if a discriminator is present.

Comment on lines +73 to +93
if "$ref" in node:
ref = node["$ref"]
name = _ref_name(ref)
if name is None:
raise _SchemaResolutionError(f"Unsupported $ref: {ref!r}")
if name in resolving:
deferred_defs.setdefault(name, {})
return {"$ref": f"#/$defs/{name}"}
if name not in components_schemas:
raise _SchemaResolutionError(
f"$ref {ref!r} not found in components.schemas"
)
resolving.add(name)
try:
inlined = recurse(components_schemas[name])
finally:
resolving.discard(name)
if name in deferred_defs:
deferred_defs[name] = inlined
return {"$ref": f"#/$defs/{name}"}
return inlined
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.

medium

To improve efficiency and avoid redundant processing, recurse should check if a model name is already present in deferred_defs before attempting to resolve it again. If it is present and has a non-empty value, it means the model has already been identified as cyclic and fully resolved, so the local ref can be returned immediately.

        if "$ref" in node:
            ref = node["$ref"]
            name = _ref_name(ref)
            if name is None:
                raise _SchemaResolutionError(f"Unsupported $ref: {ref!r}")
            if name in resolving:
                deferred_defs.setdefault(name, {})
                return {"$ref": f"#/$defs/{name}"}
            if name in deferred_defs and deferred_defs[name]:
                return {"$ref": f"#/$defs/{name}"}
            if name not in components_schemas:
                raise _SchemaResolutionError(
                    f"$ref {ref!r} not found in components.schemas"
                )
            resolving.add(name)
            try:
                inlined = recurse(components_schemas[name])
            finally:
                resolving.discard(name)
            if name in deferred_defs:
                deferred_defs[name] = inlined
                return {"$ref": f"#/$defs/{name}"}
            return inlined

@leonardmq leonardmq marked this pull request as draft April 22, 2026 02:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant