Skip to content

feat: composed attributes with derived fields#3

Open
treere wants to merge 1 commit into
mainfrom
feat/composed-attributes
Open

feat: composed attributes with derived fields#3
treere wants to merge 1 commit into
mainfrom
feat/composed-attributes

Conversation

@treere
Copy link
Copy Markdown
Owner

@treere treere commented May 29, 2026

Problem

JSONAPIPlug.Resource supports only 1:1 attribute mappings: each struct field maps to exactly one JSON API attribute. There is no mechanism for composed attributes — cases where a single stored field needs to be exposed as multiple fields in the API, or conversely, where multiple incoming fields need to be composed into one value before reaching the changeset.

A common example: a database stores full_name: "Mario/Rossi" but the API should expose and accept nome and cognome as separate fields.

Solution

A new type: :composed option for attributes, paired with composed_of: [...] to declare which derived fields participate in the mapping.

Configuration (declarative, in @derive)

@derive {JSONAPIPlug.Resource,
  type: "users",
  attributes: [
    full_name: [
      type: :composed,
      composed_of: [:nome, :cognome]
    ]
  ]
}

Transformation logic (in defimpl, following the existing protocol pattern)

defimpl JSONAPIPlug.Resource.Attribute, for: MyApp.User do
  # Serialization: real field → derived fields map
  def serialize(_resource, :full_name, full_name, _conn) do
    [nome, cognome] = String.split(full_name, "/")
    %{nome: nome, cognome: cognome}
  end

  # Deserialization: derived fields map → real field value
  def deserialize(_resource, :full_name, derived_fields, _conn) do
    "#{derived_fields[:nome]}/#{derived_fields[:cognome]}"
  end
end

Architecture

The approach reuses the existing JSONAAPlug.Resource.Attribute protocol without changing its signature. The distinction between a simple attribute and a composed one is driven entirely by the type: :composed declaration in @derive — the normalizer reads this at runtime and changes how it routes the call:

Serialization (struct → JSON API)
─────────────────────────────────
simple attribute:
  Attribute.serialize/4 → scalar value → put under attribute key

composed attribute:
  Attribute.serialize/4 → %{derived_field => value, ...}
                       → expand each entry (with recase) into JSON API attributes
                       → real field key NOT included

Deserialization (JSON API → params)
────────────────────────────────────
simple attribute:
  JSON API key → Attribute.deserialize/4 → put under attribute key

composed attribute:
  collect composed_of keys from JSON API (with recase) → %{derived_field => value}
  → Attribute.deserialize/4 → scalar value → put under real attribute key
  → derived field keys NOT included in params

Compile-time validation raises explicit errors if composed_of is missing or if serialize: false / deserialize: false are used together with type: :composed (which would be contradictory).

recase (camelize, dasherize, underscore) is applied to derived field names just like regular attributes, so the JSON API wire format is consistent regardless of case configuration.

What does NOT change

  • Existing attributes with no options, serialize: false, deserialize: false, or a custom defimpl are completely unaffected.
  • The JSONAAPlug.Resource.Attribute protocol signature is unchanged.
  • No new dependencies.

Introduces type: :composed for resource attributes that need a
bidirezional mapping between one real struct field and multiple
derived JSON API fields.

Configuration is purely declarative via @derive; transformation
logic lives in defimpl JSONAPIPlug.Resource.Attribute following
the existing protocol pattern.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant