Skip to content

Feature Spec: Schema Parser

Feature ID: FE-02 Status: Ready for Implementation Priority: P0 Parent: Tech Design v1.0 Section 8.3 SRS Requirements: FR-SCHEMA-001, FR-SCHEMA-002, FR-SCHEMA-003, FR-SCHEMA-004, FR-SCHEMA-005, FR-SCHEMA-006


1. Description

The Schema Parser converts a module's JSON Schema input_schema into Click CLI options. It handles type mapping, boolean flag pairs, enum choices, required enforcement, help text generation, and $ref/$defs resolution. The parser produces a list of click.Option objects that are appended to the dynamically generated module command.


2. Requirements Traceability

Req ID SRS Ref Description
FR-02-01 FR-SCHEMA-001 Property-to-flag mapping with type conversion.
FR-02-02 FR-SCHEMA-002 Boolean flag pairs (--flag/--no-flag).
FR-02-03 FR-SCHEMA-003 Enum-to-Choice mapping.
FR-02-04 FR-SCHEMA-004 Required property enforcement.
FR-02-05 FR-SCHEMA-005 Help text from x-llm-description or description.
FR-02-06 FR-SCHEMA-006 $ref and $defs resolution with circular detection.

3. Module Paths

  • apcore_cli/schema_parser.py — Type mapping, option generation.
  • apcore_cli/ref_resolver.py$ref resolution, allOf/anyOf/oneOf flattening.

4. Implementation Details

4.1 Function: schema_to_click_options

File: apcore_cli/schema_parser.py

Signature: schema_to_click_options(schema: dict) -> list[click.Option]

Logic steps: 1. Extract properties = schema.get("properties", {}). 2. Extract required_list = schema.get("required", []). 3. Initialize options: list[click.Option] = []. 4. Initialize flag_names: dict[str, str] = {} for collision detection. 5. For each (prop_name, prop_schema) in properties.items(): a. Compute flag_name = "--" + prop_name.replace("_", "-"). b. Check collision: if flag_name already in flag_names, exit code 48, message "Flag name collision: properties '{prop_name}' and '{flag_names[flag_name]}' both map to '{flag_name}'." c. Store flag_names[flag_name] = prop_name. d. Determine Click type via _map_type(prop_name, prop_schema). e. Determine is_required = prop_name in required_list. f. Determine help_text = _extract_help(prop_schema). g. Determine default = prop_schema.get("default", None). h. If type is boolean: create flag pair option (see section 4.2). i. Else if enum field present: create Choice option (see section 4.3). j. Else: create standard click.Option([flag_name], type=click_type, required=is_required, default=default, help=help_text). k. Append option to options. 6. Return options.

4.2 Function: _map_type

Signature: _map_type(prop_name: str, prop_schema: dict) -> click.ParamType | tuple[str, bool]

Type mapping table:

JSON Schema type Click Type Special Handling
"string" click.STRING If prop_name ends with _file or x-cli-file: true: use click.Path(exists=True).
"integer" click.INT
"number" click.FLOAT
"boolean" is_flag=True Returns marker for boolean flag pair creation.
"object" click.STRING JSON string expected. Parsed at validation time.
"array" click.STRING JSON string expected. Parsed at validation time.
Unknown type click.STRING Log WARNING: "Unknown schema type '{type}' for property '{name}', defaulting to string."
Missing type field click.STRING Log WARNING: "No type specified for property '{name}', defaulting to string."

4.3 Boolean Flag Pair Creation

When type is "boolean":

default_val = prop_schema.get("default", False)
option = click.Option(
    [f"--{flag_name}/--no-{flag_name}"],
    default=default_val,
    help=help_text,
    show_default=True,
)

Edge cases: - default: true in schema: flag default is True. User must specify --no-flag to disable. - Boolean with enum: [true]: ignore enum constraint, treat as standard boolean flag.

4.4 Enum Choice Creation

When enum field is present (and type is not boolean):

enum_values = prop_schema["enum"]
if not enum_values:  # Empty array
    logger.warning(f"Empty enum for property '{prop_name}', no values allowed.")
    # Fall through to standard string option
else:
    string_values = [str(v) for v in enum_values]
    original_types = {str(v): type(v) for v in enum_values}
    option = click.Option(
        [flag_name],
        type=click.Choice(string_values),
        required=is_required,
        default=str(default) if default is not None else None,
        help=help_text,
    )
    # Store original_types mapping for post-parse reconversion

Post-parse reconversion logic: 1. After Click parses the choice as a string, check original_types[selected_value]. 2. If original type was int, convert back: int(selected_value). 3. If original type was float, convert back: float(selected_value). 4. If original type was bool, convert back: selected_value.lower() == "true". 5. Otherwise, keep as string.

4.5 Required Property Handling

Logic steps: 1. Read required array from schema. 2. For each prop_name in required: a. If prop_name not in properties: log WARNING "Required property '{prop_name}' not found in properties, skipping." Continue. b. Set required=True on the corresponding Click option. 3. STDIN interaction: When --input - is used, required enforcement must be deferred: a. Set all options to required=False at Click level. b. After STDIN merge, validate completeness via jsonschema.validate(). c. If validation fails for missing required field: exit code 45 with field name in message.

4.6 Help Text Extraction

Function: _extract_help(prop_schema: dict) -> str | None

Logic steps: 1. Check prop_schema.get("x-llm-description"). If non-empty string, use it. 2. Else check prop_schema.get("description"). If non-empty string, use it. 3. If selected text length > 200 chars: truncate to 197 chars + "...". 4. If neither field present: return None.

4.7 Reference Resolution

File: apcore_cli/ref_resolver.py

Function: resolve_refs(schema: dict, max_depth: int = 32) -> dict

Logic steps: 1. Deep-copy the input schema to avoid mutation. 2. Extract defs = schema.get("$defs", schema.get("definitions", {})). 3. Call _resolve_node(schema, defs, visited=set(), depth=0, max_depth=max_depth). 4. Remove $defs and definitions keys from the result. 5. Return the fully inlined schema.

Function: _resolve_node(node: dict, defs: dict, visited: set, depth: int, max_depth: int) -> dict

Logic steps: 1. If "$ref" in node: a. ref_path = node["$ref"] (e.g., "#/$defs/Address"). b. If depth >= max_depth: exit code 48, message "$ref resolution depth exceeded maximum of {max_depth} for module '{module_id}'." c. If ref_path in visited: exit code 48, message "Circular $ref detected in schema for module '{module_id}' at path '{ref_path}'." d. Parse ref target: extract key from path (e.g., "Address" from "#/$defs/Address"). e. If key not in defs: exit code 45, message "Unresolvable $ref '{ref_path}' in schema for module '{module_id}'." f. Add ref_path to visited. g. Return _resolve_node(defs[key], defs, visited, depth + 1, max_depth). 2. If "allOf" in node: a. Initialize merged = {"properties": {}, "required": []}. b. For each sub-schema in node["allOf"]: - Recursively resolve the sub-schema. - Merge its properties into merged["properties"] (later entries override). - Extend merged["required"] with sub-schema's required. c. Copy any non-composition keys from node (e.g., description) into merged. d. Return merged. 3. If "anyOf" or "oneOf" in node: a. Initialize merged = {"properties": {}, "required": []}. b. Collect all_required_sets = []. c. For each sub-schema: - Recursively resolve the sub-schema. - Merge its properties into merged["properties"] (union). - Collect its required list into all_required_sets. d. Compute intersection: merged["required"] = list(set.intersection(*[set(r) for r in all_required_sets])) if all sets non-empty, else []. e. Return merged. 4. Recursively process nested properties values. 5. Return node.


5. Boundary Values & Limits

Parameter Minimum Maximum Default SRS Reference
$ref resolution depth 0 32 32 FR-SCHEMA-006
Schema composition nesting 0 3 levels FR-SCHEMA-006
Property name length 1 char No limit (ID is limited) FR-SCHEMA-001
Enum array size 0 (empty) No limit FR-SCHEMA-003
Help text truncation 200 chars FR-SCHEMA-005 AF-1

6. Error Handling

Condition Exit Code Error Message SRS Reference
Flag name collision 48 "Error: Flag name collision: properties '{a}' and '{b}' both map to '{flag}'." FR-SCHEMA-001 AF-3
Circular $ref 48 "Error: Circular $ref detected in schema for module '{id}' at path '{path}'." FR-SCHEMA-006 AF-1
$ref depth exceeded 48 "Error: $ref resolution depth exceeded maximum of 32 for module '{id}'." FR-SCHEMA-006 AF-2
Unresolvable $ref 45 "Error: Unresolvable $ref '{ref}' in schema for module '{id}'." FR-SCHEMA-006 AF-3
Unknown schema type — (WARNING) Log: "Unknown schema type '{type}' for property '{name}', defaulting to string." FR-SCHEMA-001 AF-1
Empty enum — (WARNING) Log: "Empty enum for property '{name}', no values allowed." FR-SCHEMA-003 AF-1

7. Verification

Test ID Description Expected Result
T-SCHEMA-01 Property name of type string --name flag accepts string value.
T-SCHEMA-02 Property count of type integer --count 5 passes integer 5 to module.
T-SCHEMA-03 Property rate of type number --rate 3.14 passes float 3.14.
T-SCHEMA-04 Property verbose of type boolean --verbose passes True, --no-verbose passes False.
T-SCHEMA-05 Property data of type object --data '{"key":"val"}' passes JSON string.
T-SCHEMA-06 Property items of type array --items '[1,2,3]' passes JSON string.
T-SCHEMA-07 Property input_file of type string Flag name is --input-file (underscore to hyphen).
T-SCHEMA-08 Property format with enum: ["json","csv"] --format json passes "json". --format yaml rejected by Click.
T-SCHEMA-09 Required property name omitted Click shows "Missing required option '--name'". Exit 2.
T-SCHEMA-10 Required property via STDIN echo '{"name":"test"}' \| apcore-cli exec mod --input - satisfies requirement.
T-SCHEMA-11 $ref: "#/$defs/Address" with valid def Address properties appear as top-level flags.
T-SCHEMA-12 Circular $ref (A -> B -> A) Exit 48, "Circular $ref detected".
T-SCHEMA-13 $ref depth > 32 Exit 48, "depth exceeded".
T-SCHEMA-14 allOf with two sub-schemas Merged properties from both sub-schemas.
T-SCHEMA-15 Help from x-llm-description Help text uses x-llm-description, not description.
T-SCHEMA-16 Help text > 200 chars Truncated to 197 + "...".
T-SCHEMA-17 Boolean default true Default is True without explicit flag.
T-SCHEMA-18 Enum with integer values [1,2,3] Choice accepts "1", reconverts to int 1.
T-SCHEMA-19 Two properties collide after hyphen conversion Exit 48, "Flag name collision".
T-SCHEMA-20 File property convention (_file suffix) Uses click.Path(exists=True).