Skip to content
Merged
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,29 @@ when correcting output that was wrong or incomplete on the wire.

### Fixed

- Schemas that carried enough information to type no longer degrade to
`serde_json::Value`. Across the 57-spec corpus this types 3,341 fields that
were previously opaque (#62):
- `anyOf: [$ref, {type: object, nullable: true}]` — how OData spells "that
type, or null" — becomes `Option<T>` instead of an untyped union (2,127
fields in Microsoft Graph alone);
- an inline object, union, enum, or merged `allOf` in a field or element
position is hoisted to a named type instead of being dropped by the
generator, which could not render one inline;
- `allOf` with a single member takes that member's type, and `allOf` inside
array items is analyzed instead of ignored;
- a `$ref` to any local JSON Pointer resolves — a parameter's schema, one
member of another schema's composition — not only
`#/components/schemas/<name>`;
- `type: null` becomes `()`, which serde reads and writes as `null`;
- a union whose branch list is empty takes the schema's declared type; a
union of one branch is that branch; branches differing only in constraints
share one type; branches that only alternate `required` describe the
object their properties declare; and branches that are local pointers are
expanded before the union is built;
- a schema declaring `properties` *and* a union — "these fields, and one of
these shapes" — generates the struct with the union in a
`#[serde(flatten)]` field, instead of discarding both halves (#65).
- `items: false` and `items: true` — 2020-12 boolean schemas, and the canonical
way to close a tuple — now parse instead of failing the document with "data
did not match any variant of untagged enum Schema" (#62).
Expand Down
9 changes: 9 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,17 @@ Also run the relevant distribution or corpus gate when touching these areas:
scripts/install-smoke.sh # packaging, CLI, or dependencies
scripts/spec-compile.sh anthropic openai # generator/client output
scripts/spec-compile.sh # broad generator/type changes
scripts/untyped-census.sh # anything that changes which fields get typed
```

`scripts/untyped-census.sh` rewrites `tests/conformance/untyped-report.md`,
which counts every generated field that carries `serde_json::Value` and says
why. Regenerate it when a change types fields that used to be opaque (or stops
typing ones that were), so the corpus delta is visible in review;
`scripts/untyped-census.sh --check` fails when it is stale. A **recoverable**
row means the schema carried type information the generator dropped — those are
defects with a fix, not shapes the spec left open.

The full corpus generates and compile-checks 55 OpenAPI documents and can take
several minutes. CI runs a fast generation tier on pull requests and the full
compile tier weekly or on manual dispatch.
Expand Down
102 changes: 102 additions & 0 deletions scripts/untyped-census.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
#!/usr/bin/env bash
# Report how much of the generated surface is still `serde_json::Value`, and
# why, across every spec under specs/.
#
# `serde_json::Value` in generated code means one of two things: the schema
# declared an unconstrained value, or the generator dropped type information the
# schema carried. Only the second is a defect, so the report separates them and
# ranks the recoverable causes — that ranking is what says where typing work is
# worth doing next.
#
# Usage:
# scripts/untyped-census.sh # rewrite the checked-in report
# scripts/untyped-census.sh --check # fail if the report is out of date
#
# Env:
# CENSUS_SPECS="a b" limit to named specs (default: everything in specs/)
set -euo pipefail
cd "$(dirname "$0")/.."

REPORT="tests/conformance/untyped-report.md"
CHECK=0
[ "${1:-}" = "--check" ] && CHECK=1

echo "[untyped-census] building openapi-to-rust binary..." >&2
cargo build --quiet --bin openapi-to-rust

WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT

specs=()
if [ -n "${CENSUS_SPECS:-}" ]; then
for name in $CENSUS_SPECS; do
for candidate in "specs/$name.yaml" "specs/$name.json"; do
[ -f "$candidate" ] && specs+=("$candidate")
done
done
else
for candidate in specs/*.yaml specs/*.json; do
[ -f "$candidate" ] && specs+=("$candidate")
done
fi

for spec in "${specs[@]}"; do
name="$(basename "$spec")"; name="${name%.*}"
if ! target/debug/openapi-to-rust generate "$spec" \
--output-dir "$WORK/$name" --types-only --report-untyped --json \
> "$WORK/$name.json" 2>/dev/null; then
echo "[untyped-census] skipped $name (generation failed)" >&2
rm -f "$WORK/$name.json"
continue
fi
# The findings array is followed by the generation summary; keep the array.
jq -s '.[0]' "$WORK/$name.json" > "$WORK/$name.findings.json"
done

{
echo "# Untyped Output Census"
echo
echo "Generated by \`scripts/untyped-census.sh\`. Every generated field that"
echo "carries \`serde_json::Value\`, grouped by why."
echo
echo "**Faithful** means the schema declared an unconstrained value and there is"
echo "no better Rust type. **Recoverable** means the schema carried type"
echo "information that did not survive; those are defects with a fix."
echo
echo "## Corpus totals"
echo
echo "| Reason | Count | Verdict |"
echo "|---|---:|---|"
jq -s -r '
add
| group_by(.reason)
| map({reason: .[0].reason, count: length})
| sort_by(-.count)
| .[]
| "| `\(.reason)` | \(.count) | " +
(if (.reason | test("^(any-schema|opaque-object|untyped-additional-properties|array-without-items|open-positional-items)$"))
then "faithful" else "**recoverable**" end) + " |"
' "$WORK"/*.findings.json
echo
echo "## Per spec"
echo
echo "| Spec | Untyped | Recoverable |"
echo "|---|---:|---:|"
for findings in "$WORK"/*.findings.json; do
name="$(basename "$findings")"; name="${name%.findings.json}"
total="$(jq 'length' "$findings")"
recoverable="$(jq '[.[] | select(.reason | test("^(any-schema|opaque-object|untyped-additional-properties|array-without-items|open-positional-items)$") | not)] | length' "$findings")"
echo "| \`$name\` | $total | $recoverable |"
done
} > "$WORK/report.md"

if [ "$CHECK" = "1" ]; then
if ! diff -u "$REPORT" "$WORK/report.md"; then
echo "[untyped-census] $REPORT is out of date; run scripts/untyped-census.sh" >&2
exit 1
fi
echo "[untyped-census] ✅ report is up to date"
else
cp "$WORK/report.md" "$REPORT"
echo "[untyped-census] wrote $REPORT"
fi
Loading
Loading