Skip to content

An embedded schema's additionalProperties swallows the outer schema's declared fields #50

Description

@giraffesyo

Found while fixing allOf catch-all handling in #49. Pre-existing, and not introduced by that PR — it reproduces on canary too.

Repro

components:
  schemas:
    Parent:
      type: object
      properties: { id: { type: string } }
      additionalProperties: true
    Child:
      allOf:
        - $ref: "#/components/schemas/Parent"
        - type: object
          properties: { name: { type: string } }
      additionalProperties: true
var c Child
json.Unmarshal([]byte(`{"id":"x","name":"n","extra":"e"}`), &c)
// c.Name == nil
// c.Parent.AdditionalProperties == map[extra:e name:n]

name is a declared property with a typed field, and it silently lands in the parent's catch-all map instead. A round trip looks lossless — {"id":"x","extra":"e","name":"n"} comes back out — so the bug hides from any test that only checks the JSON.

Cause

Parent has additionalProperties, so it gets MarshalJSON/UnmarshalJSON. Child embeds Parent, so Go promotes those methods onto Child. json.Unmarshal into a Child therefore runs Parent's unmarshaler, which knows only Parent's fields and sweeps everything else — including name — into Parent.AdditionalProperties.

The generated marshalers reach encoding/json via type shadow T, but a shadow still embeds Parent and so still promotes its methods. Giving Child its own catch-all does not help: json.Marshal(shadow(t)) hits the same promoted method.

Because of that, #49 deliberately does not give a catch-all to a struct whose embedded type already has one (dropShadowedCatchAlls). That keeps the pre-existing behavior for this shape rather than adding a second catch-all that would fight the first — it does not fix the swallowing.

Fix

Replace the shadow trick for structs with embedded fields. Either flatten the embedded type's fields into the shadow — shadowing each embedded type too, so no methods are promoted:

type embedded0 Parent
type shadow struct {
    embedded0
    Name *string `json:"name,omitempty"`
}

(encoding/json still promotes the exported fields of an embedded unexported struct type, so the wire shape is unchanged.)

Or generate explicit field-by-field marshaling.

Note the condition for generating marshalers has to widen too: a struct that embeds a catch-all-bearing type needs its own marshalers even when it has no catch-all of its own, otherwise the promotion still happens.

Also worth fixing alongside

Method promotion here is inconsistent in a way that will bite: with two embedded types that both have MarshalJSON, the selector is ambiguous and no method is promoted at all — so behavior flips based on how many parents a schema composes.

Tests

  • Decode into the composed type and assert the typed field is populated, not just that the JSON round-trips.
  • A composed schema with two catch-all parents.
  • Assert declared and inherited property names are emitted exactly once.

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