Plain-Language Automated Function Documentation

On this page

Status

Step Description Status

1

Save plan document and link in nav.adoc

Done (pre-ADR-030)

2

Add cargo xtask api-docs command

Done (pre-ADR-030)

3

Implement OpenAPI fetcher and AsciiDoc generator

Done (pre-ADR-030)

4

Generate initial documentation output

Done (pre-ADR-030)

5

Add generated pages to Antora nav

Done (pre-ADR-030)

6

Verification and documentation updates

Not started

Issue: #97
Branch: feature/plain-language-docs

Context

45 CFR 1355.53(a)(2) requires CCWIS to provide plain-language documentation of all automated functions. This ensures non-technical stakeholders (caseworkers, supervisors, administrators) can understand what the system does without reading code.

All 8 backend services already have comprehensive OpenAPI annotations via [utoipa::path(…​)] attributes on every endpoint. Each endpoint has a summary (short description) and response documentation. The [derive(utoipa::ToSchema)] on request/response structs provides parameter documentation. All services expose their OpenAPI spec at /api-doc/openapi.json via utoipa-swagger-ui.

Rather than maintaining separate documentation that drifts from code, this plan generates plain-language AsciiDoc pages directly from the existing OpenAPI specs. The generator fetches live specs from running services, parses them, and outputs human-readable pages organized by service and tag.

Scope

In scope:

  • cargo xtask api-docs command that generates AsciiDoc from OpenAPI specs

  • One generated page per service (8 pages total)

  • Endpoint name, plain description, parameters, request/response examples

  • Output to docs/modules/ROOT/pages/api/ directory

  • Antora nav entries under User Guide section

Out of scope:

  • Live API explorer in the web UI (separate feature)

  • Training materials or workflow guides

  • Automatic CI regeneration (manual cargo xtask api-docs for now)

Design

OpenAPI Spec Access

Every CRAIG service exposes its OpenAPI 3.0 spec at /api-doc/openapi.json. The craig-api shared crate wires this up via utoipa_swagger_ui::SwaggerUi in ApiServer::router():

// crates/craig-api/src/lib.rs
router = router.merge(SwaggerUi::new("/swagger-ui").url("/api-doc/openapi.json", doc));

Services and their ports:

Service URL

craig-rules

http://localhost:8001/api-doc/openapi.json

craig-cases

http://localhost:8002/api-doc/openapi.json

craig-placement

http://localhost:8003/api-doc/openapi.json

craig-exchange

http://localhost:8004/api-doc/openapi.json

craig-financial

http://localhost:8005/api-doc/openapi.json

craig-reporting

http://localhost:8006/api-doc/openapi.json

craig-security

http://localhost:8007/api-doc/openapi.json

craig-intake

http://localhost:8008/api-doc/openapi.json

Existing Annotation Pattern

Endpoints use #[utoipa::path(…​)] with summary, tag, params, request_body, and responses:

// services/craig-cases/src/api/cases.rs
#[utoipa::path(
    post,
    path = "/v1/cases/cases",
    tag = "Cases",
    summary = "Open a case",
    request_body = CreateCaseRequest,
    responses(
        (status = 200, description = "Case created", body = store::models::Case),
        (status = 401, description = "Unauthorized", body = craig_common::error::ProblemDetails),
    ),
)]

Request structs use #[derive(utoipa::ToSchema)] with /// doc comments on fields:

#[derive(Deserialize, utoipa::ToSchema)]
pub struct CreateCaseRequest {
    /// Investigation that substantiated the case (optional for direct filing).
    pub investigation_id: Option<Uuid>,
    pub admin_unit: String,
    pub assigned_worker: String,
}

Output Format

Each generated page follows this structure:

= Craig Cases API Reference
:description: Plain-language documentation for the Cases service API

== Cases

=== Open a Case

**POST** `/v1/cases/cases`

Opens a new case in the system, typically after an investigation has been substantiated.

.Request Fields
[cols="1,1,2"]
|===
| Field | Type | Description

| investigation_id | UUID (optional) | Investigation that substantiated the case
| admin_unit | string | Administrative unit (county) handling the case
| assigned_worker | string | Worker assigned to the case
|===

.Responses
* **200** — Case created successfully
* **401** — Authentication required

---

Generator Architecture

The generator is a pure Rust module in xtask that:

  1. Fetches OpenAPI JSON from each service via reqwest

  2. Deserializes into utoipa::openapi::OpenApi (utoipa re-exports the spec types)

  3. Iterates paths grouped by tag

  4. Renders AsciiDoc using string formatting (no template engine needed for this)

Steps

Files: docs/modules/ROOT/pages/plans/plain-language-docs.adoc, docs/modules/ROOT/nav.adoc

This plan file replaces the existing stub. Already linked in nav.adoc under Planned.

Step 2: Add api-docs Command to xtask

Files: xtask/src/cmd/mod.rs, xtask/src/cmd/api_docs.rs, xtask/src/main.rs, xtask/Cargo.toml

Add a new xtask subcommand:

// xtask/src/cmd/mod.rs — add to Command enum:
/// Generate plain-language API documentation from OpenAPI specs
ApiDocs(api_docs::ApiDocsArgs),

// xtask/src/main.rs — add match arm:
Command::ApiDocs(args) => cmd::api_docs::run(args),

Define ApiDocsArgs:

// xtask/src/cmd/api_docs.rs
use clap::Args;

#[derive(Args)]
pub struct ApiDocsArgs {
    /// Base URL for services (default: http://localhost)
    #[arg(long, default_value = "http://localhost")]
    pub base_url: String,

    /// Output directory (default: docs/modules/ROOT/pages/api)
    #[arg(long, default_value = "docs/modules/ROOT/pages/api")]
    pub output_dir: String,
}

Add dependencies to xtask/Cargo.toml:

  • reqwest = { version = "…​", features = ["rustls-tls", "json"], default-features = false }

  • serde_json = "…​" (likely already present)

  • tokio = { version = "…​", features = ["rt-multi-thread", "macros"] } (for async runtime)

Step 3: Implement OpenAPI Fetcher and AsciiDoc Generator

Files: xtask/src/cmd/api_docs.rs

use anyhow::{Context, Result};
use serde_json::Value;
use std::collections::BTreeMap;
use std::fs;
use std::path::Path;

const SERVICES: &[(&str, u16, &str)] = &[
    ("craig-rules", 8001, "Rules Engine"),
    ("craig-cases", 8002, "Case Management"),
    ("craig-placement", 8003, "Placement"),
    ("craig-exchange", 8004, "Data Exchange & ICPC"),
    ("craig-financial", 8005, "Financial & Claims"),
    ("craig-reporting", 8006, "Reporting & Data Quality"),
    ("craig-security", 8007, "Security & Compliance"),
    ("craig-intake", 8008, "Public Intake"),
];

pub fn run(args: ApiDocsArgs) -> Result<()> {
    let rt = tokio::runtime::Runtime::new()?;
    rt.block_on(async { generate_docs(&args).await })
}

async fn generate_docs(args: &ApiDocsArgs) -> Result<()> {
    let client = reqwest::Client::new();
    let output_dir = Path::new(&args.output_dir);
    fs::create_dir_all(output_dir)?;

    for (name, port, display_name) in SERVICES {
        let url = format!("{}:{}/api-doc/openapi.json", args.base_url, port);
        println!("Fetching {name} from {url}...");

        let spec: Value = client
            .get(&url)
            .send()
            .await
            .with_context(|| format!("failed to fetch {name} — is devstack running?"))?
            .json()
            .await
            .with_context(|| format!("failed to parse {name} OpenAPI JSON"))?;

        let adoc = render_service_page(&spec, name, display_name);
        let filename = output_dir.join(format!("{name}.adoc"));
        fs::write(&filename, &adoc)?;
        println!("  Wrote {}", filename.display());
    }

    // Generate index page
    let index = render_index_page();
    fs::write(output_dir.join("index.adoc"), &index)?;
    println!("Done. Generated {} service pages + index.", SERVICES.len());
    Ok(())
}

The render_service_page function should:

  1. Extract info.title and info.description from the spec

  2. Group paths by tag (from paths.{path}.{method}.tags[0])

  3. For each path+method: extract summary, description, parameters, requestBody schema, responses

  4. Render parameter tables from requestBody.content.application/json.schema — resolve $ref pointers to components.schemas

  5. Use plain language: "POST" becomes "Create", "GET" becomes "Retrieve", "PUT" becomes "Update", "DELETE" becomes "Remove"

Key rendering rules: * Group endpoints by tag with == {Tag Name} heading * Each endpoint gets === {Summary} heading * Show HTTP method and path in bold * Render request fields as a table: field name, type, required/optional, description * Render response codes as a bullet list * Resolve $ref references to inline the schema field descriptions * Skip internal fields (id, created_at, updated_at, active) from request tables — they are server-managed * Convert snake_case field names to readable labels in descriptions where doc comments are absent

Step 4: Generate Initial Documentation

Run cargo xtask api-docs with devstack running. This produces:

docs/modules/ROOT/pages/api/
  index.adoc              # Links to all service pages
  craig-rules.adoc        # Rules Engine endpoints
  craig-cases.adoc        # Case Management endpoints
  craig-placement.adoc    # Placement endpoints
  craig-exchange.adoc     # Data Exchange endpoints
  craig-financial.adoc    # Financial endpoints
  craig-reporting.adoc    # Reporting endpoints
  craig-security.adoc     # Security endpoints
  craig-intake.adoc       # Public Intake endpoints

Review the generated output for: * Completeness — every endpoint present * Readability — descriptions make sense to non-technical users * Accuracy — parameter tables match actual API contracts

If any endpoint has insufficient summary/description in its #[utoipa::path(…​)], update the annotation in the source code and regenerate.

Step 5: Add to Antora Navigation

Files: docs/modules/ROOT/nav.adoc

Add under User Guide section:

* User Guide
** xref:guide/caseworker.adoc[Caseworker Guide]
** xref:guide/supervisor.adoc[Supervisor Guide]
** xref:guide/admin.adoc[Administrator Guide]
** xref:guide/public-reporting.adoc[Public Reporting Guide]
** API Reference
*** xref:api/index.adoc[Overview]
*** xref:api/craig-rules.adoc[Rules Engine]
*** xref:api/craig-cases.adoc[Case Management]
*** xref:api/craig-placement.adoc[Placement]
*** xref:api/craig-exchange.adoc[Data Exchange]
*** xref:api/craig-financial.adoc[Financial]
*** xref:api/craig-reporting.adoc[Reporting]
*** xref:api/craig-security.adoc[Security]
*** xref:api/craig-intake.adoc[Public Intake]

Step 6: Verification and Documentation Updates

Verify:

  1. cargo xtask api-docs completes without errors against running devstack

  2. All 8 service pages are generated with correct endpoint counts

  3. cargo xtask check-docs passes (no broken xrefs in nav.adoc)

  4. Each page is human-readable — a non-developer can understand what each endpoint does

  5. Parameter tables are accurate (compare against actual request structs)

Files Touched

File Change

xtask/src/cmd/api_docs.rs

New file: OpenAPI fetcher + AsciiDoc generator

xtask/src/cmd/mod.rs

Add pub mod api_docs; and ApiDocs variant to Command enum

xtask/src/main.rs

Add Command::ApiDocs match arm

xtask/Cargo.toml

Add reqwest, tokio dependencies (if not already present)

docs/modules/ROOT/pages/api/*.adoc

Generated output (9 files: index + 8 services)

docs/modules/ROOT/nav.adoc

Add API Reference sub-section under User Guide

Verification

  1. cargo nextest run --workspace --lib — unit tests pass

  2. cargo xtask dev reload — devstack running

  3. cargo xtask api-docs — generates all 9 files without error

  4. cargo xtask check-docs — no broken nav xrefs

  5. Manual review of at least 3 generated pages for readability

Documentation Updates

  • CHANGELOG.adoc — entry under == Unreleased

  • .claude/docs/coding-conventions.md — add api-docs to xtask subcommand list

  • Generated API docs are the deliverable itself — no separate doc update needed

Edit this page · latest