Documentation
¶
Overview ¶
Package typegen translates OpenAPI v3 schemas into runtime reflect.Type values that Scriggo templates can consume with full compile-time field safety.
The K8s ecosystem already exposes schemas for every resource we watch — core types via the cluster's OpenAPI v3 endpoint, CRDs via their own .spec.versions[].schema.openAPIV3Schema. None of the existing tooling (client-gen, controller-gen, openapi-gen) generates Go types at runtime though — they all run at code-generation time. This package fills that gap.
The translation rules ¶
- object → reflect.StructOf with one StructField per Properties entry; field Name is the JSON name capitalised so reflect can see it (Go's exported-identifier rule), plus a `json:"<original-name>"` tag so encoding/json can still unmarshal an unstructured map into the generated type.
- array → reflect.SliceOf the element schema's generated type.
- string → reflect.TypeOf("").
- integer → reflect.TypeOf(int64(0)). K8s schemas don't distinguish int32 from int64 reliably; int64 covers both and matches what unstructured.Unstructured produces.
- boolean → reflect.TypeOf(false).
- number → reflect.TypeOf(float64(0)).
- $ref → resolved against the spec's Components.Schemas, with the resulting type memoised so recursive refs terminate.
- allOf / oneOf / anyOf, schema with no type, schema with x-kubernetes-preserve-unknown-fields=true, or AdditionalProperties pointing to a true-bool (free-form map) all degrade to interface{} (any). Templates fall back to dig() for those subtrees.
Why types and not raw maps ¶
Scriggo type-checks field access against registered types at template compile time. A typo in `gw.Metadata.Naamespace` is caught when the controller boots, not at the next reconcile when the rendered output is missing a frontend block. The map-based shape we have today (every K8s object as map[string]any) can't be type-checked because Scriggo rejects field access on `any` — that's why HAPTIC templates universally use dig(), and why we ended up shipping digstr/digint/digbool to collapse the boilerplate. Generating real Go types from OpenAPI lets templates drop the navigation helpers entirely for the typed envelope, while still keeping dig() working on the Spec/Status subtrees (which are resource-specific and may still carry preserve-unknown subtrees).
Cycle handling ¶
Real K8s schemas mostly aren't recursive — RawExtension is the usual suspect and it's already preserve-unknown so it degrades to any. We still cap recursion depth defensively, returning interface{} when the limit is hit. The depth limit only kicks in when a schema $ref-chains further than DefaultMaxDepth without resolving — a real cycle would hit this. Constructive schemas terminate before it because the type cache is keyed by $ref and the second visit returns the cached type.
What this package does NOT do ¶
Schema fetching lives separately in pkg/k8s/schemafetcher — the fetcher does cluster I/O so it belongs on the K8s integration layer, not under templating (which is meant to stay pure; see arch-go.yml Rule 7). Wrapping a map[string]any into an instance of a generated type lives in pkg/k8s/typegen.WrapInto (this package, separate file). The controller-side bootstrap that glues fetcher+converter+Scriggo together lives in pkg/controller. The package itself has no I/O.
Index ¶
Constants ¶
const DefaultMaxDepth = 32
DefaultMaxDepth is the recursion cap the Converter uses when no explicit MaxDepth is set on the receiver. Chosen to comfortably exceed every nested schema we've seen in the Kubernetes core API and the Gateway API CRDs (deepest path observed is ~7 — HTTPRouteSpec.Rules[]. Matches[].Headers[]). Anything deeper than this is almost certainly a cycle we can't statically prove won't terminate, and falling back to any is the safe choice.
Variables ¶
This section is empty.
Functions ¶
func GoFieldName ¶
GoFieldName lifts a JSON property name into an exported Go identifier suitable for reflect.StructField.Name. The transformation is the minimum needed for Scriggo's compile-time field lookup to find the field by its capitalised name:
- first rune is uppercased (Scriggo and Go reflection both require an exported field for outside-package access; reflect.StructOf panics on lowercase first letters);
- non-letter / non-digit runes are replaced with '_' so the result is a valid Go identifier (K8s fields are normally already identifier-shaped — apiVersion, allowedListeners — but tolerating '/' / '.' / '-' costs nothing and protects against schemas we haven't seen);
- an empty input yields "_" because reflect.StructOf panics on empty Name.
Acronym preservation (e.g. apiVersion → APIVersion) is deliberately NOT performed. The cost is one extra rule for template authors to internalise; the win is no acronym dictionary to maintain. So: `apiVersion` → `ApiVersion`, `tlsConfig` → `TlsConfig`. Templates write `gw.ApiVersion`.
It is exported because callers outside this package (e.g. pkg/controller/typebootstrap) need the same identifier rule to compose the `resources` struct's Go field names from watched-resource keys.
func WrapInto ¶
WrapInto converts an unstructured Kubernetes object (the map[string]any shape every watcher in pkg/k8s normalises to) into a reflect.Value of the generated type produced by Converter.Convert for the same resource's schema.
The implementation round-trips through encoding/json on purpose: every property the Converter emits carries a `json:"<original>"` struct tag, so encoding/json's reflection-based unmarshaller understands exactly how to drive each field. The alternative — a hand-rolled reflect-based copier — would duplicate logic that the standard library has already battle-hardened (number parsing, slice growth, nested map handling, polymorphic [any] field passthrough). The round-trip cost is paid once per resource per snapshot load (the existing StoreWrapper caches snapshots per render), and on the same order as the unstructured-map walks the chart's dig() already does.
On any unmarshal error WrapInto returns the zero reflect.Value and the error. Callers in the controller hot path should log-and-skip rather than fail the whole reconcile — a single malformed resource shouldn't take down the renderer.
Types ¶
type Converter ¶
type Converter struct {
// MaxDepth caps how deep Convert will recurse before returning any.
// Defaults to [DefaultMaxDepth] when unset. Exposed mainly so tests
// can force a shallow cap and verify the fallback path.
MaxDepth int
// IgnoreFields lists JSONPath patterns whose targets should be
// dropped from the generated type. Mirrors the format of
// HAProxyTemplateConfig.spec.watchedResourcesIgnoreFields and
// reuses the [k8s.io/client-go/util/jsonpath] parser so the
// runtime field filter (pkg/k8s/indexer) and this converter
// agree on which patterns are well-formed and how their
// segments break down. Mismatch between the two would let
// templates compile against schema-declared fields that the
// watcher strips before storage — reliably zero at render time
// with no diagnostic.
//
// The converter doesn't disambiguate by pattern *syntax* —
// `metadata.annotations.k` and `metadata.annotations['k']`
// parse to the same FieldNode chain in K8s JSONPath, by
// design. Instead the SCHEMA WALK decides what stripping is
// possible: ignoreSet entries are checked only at the
// per-property iteration in convertObject. Map-key patterns
// (whose target sits inside an `additionalProperties` subtree)
// never see that iteration because the converter doesn't
// recurse into a map's value space; the runtime filter still
// removes the key, the typed shape stays intact. Plain
// whole-property patterns DO match an iteration and strip the
// field. Array-index / wildcard / filter / recursive patterns
// don't survive parseDottedJSONPath at all — they couldn't
// strip a typed shape even in principle.
//
// Examples:
// "metadata.managedFields" → Metadata struct has no ManagedFields
// "metadata.annotations['k']" → Annotations stays map[string]string
// "spec.rules[0].host" → no type change (ArrayNode)
//
// CAVEAT for $ref-shared subschemas:
//
// When a schema component is referenced from multiple property
// paths (e.g. ObjectMeta referenced at both `metadata` and
// `template.metadata`), the converter resolves the ref only
// once and caches the resulting type. The ignore-pattern
// matching uses the FIRST path visited, which is determined by
// the converter's deterministic alphabetical iteration over
// sibling properties. A pattern like `template.metadata.
// managedFields` would silently no-op when `metadata` comes
// first alphabetically: the ObjectMeta type gets resolved at
// the `metadata` path (which doesn't match the pattern), is
// cached, and the later `template.metadata` visit hits the
// cache and inherits the un-stripped type.
//
// Use the alphabetically-first path when authoring an ignore
// pattern targeting a $ref-shared schema. For the K8s standard
// case — ObjectMeta referenced once per resource at the
// `metadata` path — this is automatically satisfied and the
// canonical pattern `metadata.managedFields` works as expected.
//
// Set this BEFORE calling Convert. Mutating after a Convert call
// is undefined; build a new Converter instead.
IgnoreFields []string
// contains filtered or unexported fields
}
Converter translates an OpenAPI v3 spec.Schema tree into a runtime reflect.Type via reflect.StructOf. Construct one per logical batch of schemas (e.g. one per controller boot) so the $ref cache stays scoped to a coherent set of definitions — sharing a converter across unrelated specs would muddle the cache keys and risk cross-spec ref hijacking on identically-named refs (e.g. /v1.ObjectMeta vs /v1beta1.ObjectMeta).
The zero value is not usable; call NewConverter instead.
func NewConverter ¶
NewConverter builds a Converter whose $ref resolution targets the supplied components map (typically [spec3.OpenAPI].Components.Schemas converted to the v2-shaped spec.Schema via the adapter in pkg/k8s/typegen/adapters.go). Pass nil when callers only feed inline schemas with no $ref pointers.
func (*Converter) Convert ¶
Convert returns the reflect.Type that corresponds to schema. The returned type is always non-nil — degraded subtrees and unrepresentable shapes (oneOf without a common shape, schemas with no type and no $ref, etc.) collapse to interface{} rather than producing an error. Errors are reserved for genuine structural problems: a $ref that can't be resolved against the supplied components, or an array schema with no Items.