refactor: strip optional null from all component schemas, not just request bodies

Extends stripOptionalNull to run over every component schema, closing
~200 null-vs-undefined mismatches between Hono and HttpApi SDK output.
Removes LegacyBodyRefParameters since components are now pre-cleaned.
Adds scripts/diff-sdk-types.sh for comparing Hono vs HttpApi SDK types.
This commit is contained in:
Kit Langton
2026-04-28 21:39:42 -04:00
parent 51bc4246d2
commit a35dc04d15
2 changed files with 61 additions and 18 deletions
+51
View File
@@ -0,0 +1,51 @@
#!/bin/bash
# Compare SDK types generated from Hono vs HttpApi specs.
# Sorts types alphabetically so only meaningful body differences show.
#
# Usage: ./scripts/diff-sdk-types.sh # full diff
# ./scripts/diff-sdk-types.sh --stat # summary only
set -euo pipefail
DIR="$(cd "$(dirname "$0")/.." && pwd)"
SDK="$(cd "$DIR/../sdk/js" && pwd)"
normalize() {
python3 -c "
import re, sys
content = open(sys.argv[1]).read()
blocks = re.split(r'(?=^export (?:type|function|const) )', content, flags=re.MULTILINE)
header, body = blocks[0], blocks[1:]
body.sort(key=lambda b: m.group(1) if (m := re.match(r'export \w+ (\w+)', b)) else '')
sys.stdout.write(header + ''.join(body))
" "$1"
}
echo "Generating Hono SDK..." >&2
(cd "$SDK" && bun run script/build.ts >/dev/null 2>&1)
normalize "$SDK/src/v2/gen/types.gen.ts" > /tmp/sdk-types-hono.ts
git -C "$SDK" checkout -- src/ 2>/dev/null
echo "Generating HttpApi SDK..." >&2
(cd "$SDK" && OPENCODE_SDK_OPENAPI=httpapi bun run script/build.ts >/dev/null 2>&1)
normalize "$SDK/src/v2/gen/types.gen.ts" > /tmp/sdk-types-httpapi.ts
git -C "$SDK" checkout -- src/ 2>/dev/null
echo "" >&2
if [[ "${1:-}" == "--stat" ]]; then
honly=$(diff /tmp/sdk-types-hono.ts /tmp/sdk-types-httpapi.ts | grep -c '^< export type' || true)
aonly=$(diff /tmp/sdk-types-hono.ts /tmp/sdk-types-httpapi.ts | grep -c '^> export type' || true)
total=$(diff /tmp/sdk-types-hono.ts /tmp/sdk-types-httpapi.ts | wc -l | tr -d ' ')
echo "Hono-only: $honly types HttpApi-only: $aonly types Diff lines: $total"
echo ""
if [[ $honly -gt 0 ]]; then
echo "=== Hono-only types ==="
diff /tmp/sdk-types-hono.ts /tmp/sdk-types-httpapi.ts | grep '^< export type' | sed 's/< export type / /' | sed 's/ .*//'
echo ""
fi
if [[ $aonly -gt 0 ]]; then
echo "=== HttpApi-only types ==="
diff /tmp/sdk-types-hono.ts /tmp/sdk-types-httpapi.ts | grep '^> export type' | sed 's/> export type / /' | sed 's/ .*//'
fi
else
diff /tmp/sdk-types-hono.ts /tmp/sdk-types-httpapi.ts || true
fi
@@ -58,10 +58,6 @@ const InstanceQueryParameters = [
},
] satisfies OpenApiParameter[]
// These refs already match the legacy SDK's expected body shape. Expanding all
// other refs lets us strip Effect's `null` from optional fields in one place.
const LegacyBodyRefParameters = new Set(["Auth", "Config", "Part", "WorktreeRemoveInput", "WorktreeResetInput"])
// Query schemas describe decoded Effect values, but the generated SDK needs the
// public call shape. These keep SDK callers passing numbers/booleans while the
// server still decodes string query params at runtime.
@@ -82,6 +78,13 @@ function matchLegacyOpenApi(input: Record<string, unknown>) {
// actual schema from any parent union that references them.
fixSelfReferencingComponents(spec)
// Effect's Schema.optional emits `anyOf: [T, {type:"null"}]` in OpenAPI,
// but the legacy SDK expected plain `T` for optional fields. Strip null
// from all component schemas so both request and response types match.
for (const [name, schema] of Object.entries(spec.components?.schemas ?? {})) {
spec.components!.schemas![name] = stripOptionalNull(structuredClone(schema))
}
for (const [path, item] of Object.entries(spec.paths ?? {})) {
const isInstanceRoute = !path.startsWith("/global/") && !path.startsWith("/auth/")
for (const method of ["get", "post", "put", "delete", "patch"] as const) {
@@ -91,23 +94,12 @@ function matchLegacyOpenApi(input: Record<string, unknown>) {
// Hono's generated OpenAPI never marked request bodies as required. Keep
// that SDK surface stable during the HttpApi migration.
delete operation.requestBody.required
// Effect's Schema.optional emits `anyOf: [T, {type:"null"}]` in OpenAPI,
// but the legacy SDK expected plain `T` for optional fields. Expand
// non-legacy $refs and strip the null arms so the SDK surface is stable.
for (const media of Object.values(operation.requestBody.content ?? {})) {
const ref = media.schema?.$ref?.replace("#/components/schemas/", "")
if (ref && LegacyBodyRefParameters.has(ref)) continue
if (ref && spec.components?.schemas?.[ref]) {
media.schema = stripOptionalNull(structuredClone(spec.components.schemas[ref]))
continue
}
if (media.schema) media.schema = stripOptionalNull(media.schema)
}
if (path === "/experimental/workspace" && method === "post") {
// Workspace creation fields `branch` and `extra` are Schema.NullOr —
// genuinely nullable, not just optional. Re-add the null that the
// global strip above removed.
const properties = operation.requestBody.content?.["application/json"]?.schema?.properties
// component-level strip above removed.
const ref = operation.requestBody.content?.["application/json"]?.schema?.$ref?.replace("#/components/schemas/", "")
const properties = ref ? spec.components?.schemas?.[ref]?.properties : operation.requestBody.content?.["application/json"]?.schema?.properties
if (properties?.branch) properties.branch = { anyOf: [properties.branch, { type: "null" }] }
if (properties?.extra) properties.extra = { anyOf: [properties.extra, { type: "null" }] }
}