Skip to content

Schema-driven code generation for versioned fluent API (V2/V3) #67

Description

@lilith

Summary

The hand-written fluent API (~2000 LOC of BuildNode methods, encoder presets, enum converters, DTO serialization) mirrors types defined in the native imageflow Rust source. As Imageflow 3 drastically expands the API surface, maintaining hand-written parity is untenable. We need schema-driven code generation for versioned namespaces (Imageflow.Fluent.V2, Imageflow.Fluent.V3).

Architecture

Two schema sources

V2 (current API, OpenAPI): The native library embeds an OpenAPI 3.1 schema (94 component schemas) accessible via imageflow_context_send_json("v1/schema/openapi/latest/get", "{}"). The Node enum is a oneOf with serde-style tagged variants. EncoderPreset is similar. All types derive Serialize/Deserialize + utoipa ToSchema.

V3 (new API, zennode): The zennode metadata system provides much richer per-node information than OpenAPI alone:

  • NodeSchema: id, label, description, group, role, params, tags, version, compat_version, json_key, coalesce info, format preferences
  • ParamDesc: name, label, description, kind (11 types: Float/Int/U32/Bool/Str/Enum/FloatArray/Color/Json/Object/TaggedUnion), unit, section, slider mapping, kv_keys (RIAPI aliases), since_version, visible_when, optional, json_name, json_aliases
  • ParamKind: carries min/max/default/identity/step for numerics, variants for enums, sub-params for objects, tagged variants for unions
  • NodeRole: Decode, Geometry, Orient, Resize, Filter, Composite, Analysis, Encode, Meta, Canvas
  • NodeGroup: Decode, Encode, Tone, ToneRange, ToneMap, Color, Detail, Effects, Geometry, Layout, Canvas, Composite, Quantize, Analysis, Hdr, Raw, Auto, Other

Each zen crate (zenfilters, zenresize, etc.) defines nodes via #[derive(Node)] with rich annotations:

#[derive(Node, Clone, Debug, Default)]
#[node(id = "zenfilters.exposure", group = Tone, role = Filter)]
#[node(label = "Exposure")]
#[node(format(preferred = OklabF32, alpha = Skip))]
#[node(coalesce = "fused_adjust")]
pub struct Exposure {
    /// Exposure compensation in stops (+/-)
    #[param(range(-5.0..=5.0), default = 0.0, identity = 0.0, step = 0.1)]
    #[param(unit = "EV", section = "Main", slider = Linear)]
    pub stops: f32,
}

This metadata drives:

  • Type-safe fluent builders with parameter validation (ranges, required vs optional)
  • Enum definitions with labels and descriptions
  • RIAPI querystring mapping (every parameter can have multiple kv_keys aliases)
  • Conditional visibility rules for UI-aware APIs
  • Documentation from descriptions, units, labels, groups

Versioned namespaces

  • Imageflow.Fluent — existing hand-written V2 API (stays as-is during transition)
  • Imageflow.Fluent.V2 — generated from OpenAPI schema, replaces hand-written code. Drops deprecated methods.
  • Imageflow.Fluent.V3 — generated from zennode metadata, adds all new zen operations. Partial classes allow hand-written shims for non-schema nodes.

new ImageJob() gets deprecated in favor of:

  • ImageJob.V2(backend) with backend selection (Latest, ExactOrFail, Closest)
  • ImageJob.V3()

Generator tool: Imageflow.CodeGen

A .NET console app in this repo that:

  1. Loads imageflow native library via existing P/Invoke bindings
  2. Calls schema endpoints to extract OpenAPI schema and RIAPI vocabulary from the DLL itself
  3. For V3: calls zennode registry endpoints (new) to get NodeSchema metadata for all registered nodes
  4. Reads a sidecar node_semantics.json for V2 graph-semantic enrichment (node role classification, C# name mappings, convenience method definitions)
  5. Emits .g.cs files committed to the repo (not ephemeral — reviewable in diffs)

Fallback: when native lib isn't available, fetch schema from CI artifacts.

What gets generated

Per-node (both V2 and V3)

  • BuildNode fluent methods — one per node variant, returning BuildNode for filters, using NodeWithCanvas() for canvas ops
  • Parameter validation — V3 gets range validation from ParamKind metadata
  • Convenience overloads — e.g., ConstrainWithin(w, h) as shortcut for Constrain(mode=Within)
  • XML doc comments from schema descriptions

Shared types

  • Enums (Filter, ConstraintMode, CompositingMode, PixelFormat, etc.) with snake_case JSON conversion
  • Config DTOs (Constraint, ResampleHints, Watermark, SecurityOptions) with ToJsonNode() methods
  • Tagged unions (Color, RoundCornersMode, EncoderPreset) following serde's externally-tagged pattern
  • Encoder preset classes implementing IEncoderPreset
  • Response types for all endpoints (JobResult, ImageInfo, decode/encode results)
  • RIAPI types (QueryStringSchema, validation results)
  • Schema endpoint wrappers (SchemaClient with typed access to schema/RIAPI/validation)

What stays hand-written

  • ImageJob (I/O lifecycle, graph assembly, Decode/CreateCanvas/BuildCommandString)
  • BuildNode class core (To(), NodeWithCanvas(), Branch(), Encode())
  • BuildItemBase, BuildEndpoint, FinishJobBuilder
  • All I/O abstractions (MemorySource, StreamSource, BytesDestination, etc.)
  • P/Invoke bindings (NativeMethods, JobContext)
  • SchemaClient wrapper class (uses generated types, manages JobContext lifecycle)

Generated output structure

src/Imageflow/Generated/
  V2/
    Enums.g.cs
    ConfigTypes.g.cs
    ColorTypes.g.cs
    SecurityTypes.g.cs
    EncoderPresets.g.cs
    BuildNode.Operations.g.cs
    BuildNode.Convenience.g.cs
    ResponseTypes.g.cs
  V3/
    Enums.g.cs
    Nodes.g.cs                    -- all zen node fluent methods
    NodeParams.g.cs               -- parameter DTOs with validation
    EncoderPresets.g.cs
    BuildNode.Operations.g.cs
    ResponseTypes.g.cs
  Shared/
    SchemaTypes.g.cs              -- RIAPI schema types
    RiapiTypes.g.cs               -- RIAPI vocabulary

Constraints

  • AOT/trimming compatible: all serialization via JsonObject/JsonNode (no reflection). Response deserialization via [JsonSerializable] context. No Newtonsoft.Json.
  • Generated code is committed: appears in diffs, reviewable. Not ephemeral build artifacts.
  • Backward compatible: V2 generated code must produce identical JSON to the current hand-written code (verified by JSON equivalence tests).
  • Package validation: EnablePackageValidation catches binary API breaks.

Schema endpoints already available in native lib

Endpoint Returns
v1/schema/openapi/latest/get Full OpenAPI 3.1 schema as JSON string
v1/schema/riapi/latest/get RIAPI querystring schema
v1/schema/riapi/latest/list_keys List of 100+ supported RIAPI keys
v1/schema/riapi/latest/validate Validate a querystring against schema
v1/schema/list-schema-endpoints List all schema endpoints
v1/schema/json/latest/v1/all JSON schemas for all V1 endpoints (with json-schema feature)

Why not alternatives

  • Roslyn Source Generator: can't read external 112KB JSON or call native P/Invoke at compile time. Opaque output, hard to diff.
  • T4 Templates: deprecated, poor tooling, no AOT awareness.
  • OpenAPI Generator CLI: produces NodeOneOf10ExpandCanvas-style naming, uses Newtonsoft.Json, no graph-semantic awareness. Fine for other language bindings (TypeScript/Ruby), wrong tool for the fluent API.
  • Hand-written V3: too many nodes (zenfilters alone has 30+), each with rich typed parameters. The metadata is the code — generating from it is the only maintainable path.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions