Skip to content

Collapse the Config/Request type pairs in natmap models #42

Closed

Description

@FAZuH

Part of the whole-repo over-engineering audit (2026-09-30).

What to build

One type per rule kind on the wire. The *Request twins disappear and the axum handlers take the *Config type directly.

Findings

DnatConfig and DnatRequest have byte-identical field lists. So do SnatConfig/SnatRequest, HairpinConfig/HairpinRequest, and PolicyRouteConfig/PolicyRouteRequest, the last pair identical down to the #[derive] line. All four *Config types already derive Deserialize. The cost is four From impls (44 lines of pure field copying) plus seven hand-written field-by-field copy sites in crates/natmap/src/api.rs.

This is Duplicated Knowledge with a Primitive Obsession flavour: the wire shape has two names, and nothing about the pair is a real distinction (a request is a config, and a config is a request).

Acceptance criteria

  • DnatRequest, SnatRequest, HairpinRequest and PolicyRouteRequest are gone, along with their four From impls
  • Every handler in crates/natmap/src/api.rs takes Json<*Config> directly
  • The four NatmapClient methods that took a *Request now take the *Config
  • The JSON on the wire is byte-identical before and after
  • cargo test -p lab-ops_natmap passes; crates/natmap/tests/model.rs and the api.rs request tests still cover the same serialisation paths
  • No custom error type introduced; color_eyre::Result per standards.md §4.1

Blocked by

None (can start immediately).

Estimated: -125 lines, -8 declared items, -12 signatures collapsed. Related: #24, #25.

Worked example, from crates/natmap/src/api.rs:84 and seven sibling handlers:

// before
pub async fn add_dnat(State(state): State<AppState>, Json(req): Json<DnatRequest>)
// after
pub async fn add_dnat(State(state): State<AppState>, Json(req): Json<DnatConfig>)

Activity

  1. FAZuH commented on Sep 30, 2026

    @FAZuH
    OwnerAuthor

    Acceptance-criteria deviation: lan_cidr no longer serialises as null

    Found in review of #42. The criterion "The JSON on the wire is byte-identical before and after" is not met for one field, and it is recorded here rather than left implicit.

    HairpinRequest (deleted) vs HairpinConfig (the merged replacement):

    // before — HairpinRequest
    #[serde(default)]
    pub lan_cidr: Option<String>,
    
    // after — HairpinConfig
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub lan_cidr: Option<String>,

    The wire delta

    lan_cidr: None (the common case — lan_cidr only applies to preserve_src_ip hairpin):

    // before
    {"ext_ip":"203.0.113.50","int_ip":"10.0.0.99","ports":"8080","proto":"tcp","lan_cidr":null}
    // after
    {"ext_ip":"203.0.113.50","int_ip":"10.0.0.99","ports":"8080","proto":"tcp"}

    lan_cidr: Some(..) is unchanged:

    {"ext_ip":"203.0.113.50","int_ip":"10.0.0.99","ports":"8080","proto":"tcp","lan_cidr":"192.168.1.0/24"}

    This is the only serialisation delta in #42: skip_serializing_if appears exactly once in crates/natmap/src/models.rs:199.

    No functional break — verified, not assumed

    1. The deserialise side has a default. Both types carry #[serde(default)], so an absent lan_cidr key deserialises to None exactly as an explicit null does. A probe test confirmed the two forms produce equal values:

      absent-> None
      null  -> None
      assert_eq!(absent, null)   // passes
      
    2. Nothing asserts the literal null. rg -n 'null' crates/natmap/ tests/ returns only 2>/dev/null shell redirects and Consul jq null-guards — no test asserts a JSON null in a hairpin body.

    Decision: accept the delta

    Keeping the one-type-per-rule shape that #42 exists to produce is worth more than reproducing a redundant null key. Re-adding a serialisation attribute purely to re-emit "lan_cidr": null would reintroduce exactly the kind of noise this pass is deleting, and both ends of this workspace ship together. The omission is also the better wire format.

    skip_serializing_if is left in place. Flagging so the deviation is auditable rather than hidden.

  2. FAZuH commented on Sep 30, 2026

    @FAZuH
    OwnerAuthor

    Implemented in 50db2ad (refactor) and 1c7ffe9 (docs corrections).

    Deviation from #42's "byte-identical" criterion: POST/DELETE /hairpin now omits lan_cidr instead of sending null, because HairpinConfig carries skip_serializing_if and the deleted HairpinRequest did not. Verified empirically that absent and explicit null both deserialise to None, and that no test asserts a JSON null. Accepted deliberately rather than reintroducing a redundant null key.

    Verified: cargo clippy --workspace --all-targets --all-features --no-deps clean; cargo test -p lab-ops_natmap (189), -p lab-ops_auto-discover (67), -p lab-ops_lab-lib (35), -p lab-ops --lib (65) all pass. The iptables argv tests have no diff, so the generated rules are provably unchanged. A review pass found no remaining defects.

    Not run: the 122+ Docker integration suite, per AGENTS.md. It compiles; you trigger it. ./dev.sh test or ./dev.sh all.

    Not pushed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    ready-for-agentFully specified, ready for an AFK agent

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions