Class: Plumb::Codec

Inherits:
Object
  • Object
show all
Includes:
Composable
Defined in:
lib/plumb/codec.rb

Overview

A group of Encoders that applies itself to whole types at composition time. A Codec knows nothing about any particular format — only its encoders, plus a list of pass-through types (.noop) already valid in the target format.

class JSONCodec < Plumb::Codec
noop Types::String, Types::Integer, Types::Float, Types::Numeric,
     Types::True, Types::False, Types::Nil, Types::Hash, Types::Array

encoder JSONDateRangeEncoder    # Hash[from: Date, to: Date] <=> DateRange
encoder ISODateEncoder          # String <=> Date
end

JSONPerson    = JSONCodec >> Person  # decode: input structures -> Person
EncodedPerson = Person >> JSONCodec  # encode: Person -> output structures

Composing rewrites the type deeply: every field (at any depth) whose type matches an encoder's output type is replaced with the oriented encoder step (a plain Function, see Encoder.step); noop-matched types pass through unchanged; anything else raises Plumb::TypeError at composition time, naming the field path. An encoder's input type is itself rewritten through the same codec, so nested non-native values resolve via other encoders in the group.

The Codec leaves no runtime node behind — the result is ordinary Plumb algebra, so parsing, subtyping and visitors work on it unchanged. It works on any type, not just Hash schemas: JSONCodec >> Types::Date returns the matched encoder's decode step.

Direct Known Subclasses

Forms, JSON

Defined Under Namespace

Classes: DateEncoder, DecimalEncoder, Forms, JSON, RangeEncoder, Rewriter, SymbolEncoder, TimeEncoder, URIEncoder

Constant Summary collapse

TIME_EXPR =

Shared, format-neutral encoders — string representations standardized by ISO 8601 (dates, times) and RFC 3986 (URIs) — registered by both built-in codecs, and usable per-field in any schema. Their input types carry format: metadata, so generated JSON Schemas describe the fields as eg. {type: "string", format: "date"}.

/\A\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2})?(\.\d+)?(Z|[+-]\d{2}:?\d{2})?)?\z/
URI_EXPR =

Scheme-prefixed URI strings, per RFC 3986 — URI.parse alone is too permissive (a blank string is a valid URI).

/\A[a-z][a-z0-9+\-.]*:/i
FLOAT_EXPR =

Decimal or scientific notation — Float#to_s emits scientific for very small/large magnitudes ("1.0e-05"), so the input type must accept what encode produces or a valid Float fails its own round-trip.

/\A-?\d+(\.\d+)?([eE][+-]?\d+)?\z/
HTTPURIEncoder =

Re-parameterized subclasses: same input type and methods, narrower output — the produced URI is validated against the declared class.

FileURIEncoder =

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Methods included from Composable

#/, #[], #absorb_input, #absorb_output, #accepted_type, #as_node, #build, #check, #children, #defer, #fusable_step?, #fuse_with, #generate, #idempotent?, included, #input_type, #invalid, #invoke, #match, #metadata, #not, #output_type, #pipeline, #policy, resolve_operand, #static, #subtype_identity, #to_json_schema, #to_mermaid, #to_s, #transform, #value, #value_preserving?, #where, #with, wrap

Methods included from Callable

#parse, #resolve

Constructor Details

#initialize(*extra_encoders) ⇒ Codec

Returns a new instance of Codec.

Parameters:

  • extra_encoders (Array<Class>)

    encoders for this instance, appended after (and winning ties over) the class-level registry.



98
99
100
101
102
103
# File 'lib/plumb/codec.rb', line 98

def initialize(*extra_encoders)
  @encoders = self.class.encoders + extra_encoders
  @noop_types = self.class.noop_types
  @noop_union = @noop_types.reduce(:|)
  freeze
end

Instance Attribute Details

#encodersObject (readonly)

Returns the value of attribute encoders.



94
95
96
# File 'lib/plumb/codec.rb', line 94

def encoders
  @encoders
end

#noop_typesObject (readonly)

Returns the value of attribute noop_types.



94
95
96
# File 'lib/plumb/codec.rb', line 94

def noop_types
  @noop_types
end

Class Method Details

.&(other) ⇒ Object



75
# File 'lib/plumb/codec.rb', line 75

def &(other) = instance & other

.>>(other) ⇒ Object



73
# File 'lib/plumb/codec.rb', line 73

def >>(other) = instance >> other

.call(result) ⇒ Object



79
# File 'lib/plumb/codec.rb', line 79

def call(result) = instance.call(result)

.encoder(*encoder_classes) ⇒ Object

Register one or more Encoder subclasses, in matching-priority order. The registry is inheritable: subclassing a codec extends it, and a subclass's encoders take precedence over inherited ones when their types are equivalent.



43
44
45
46
47
48
49
50
51
52
# File 'lib/plumb/codec.rb', line 43

def encoder(*encoder_classes)
  encoder_classes.each do |enc|
    unless enc.is_a?(::Class) && enc < Encoder
      raise ArgumentError, "expected an Encoder subclass, got #{enc.inspect}"
    end

    own_encoders << enc
  end
  self
end

.encodersObject

All registered encoders, inherited first, own last (later registrations win ties in matching).



65
# File 'lib/plumb/codec.rb', line 65

def encoders = inherited_registry(:encoders, own_encoders)

.for(type) ⇒ Object



76
# File 'lib/plumb/codec.rb', line 76

def for(type) = instance.for(type)

.instanceObject

Class-level composition delegates to a memoized instance, so a Codec subclass composes directly: JSONCodec >> Person.



72
# File 'lib/plumb/codec.rb', line 72

def instance = @instance ||= new

.noop(*types) ⇒ Object

Register pass-through types: values of these types are already valid in the codec's target format and are left untouched — they rewrite to nothing. Real encoders always match first, so a noop never shadows a registered encoder for the same type.



58
59
60
61
# File 'lib/plumb/codec.rb', line 58

def noop(*types)
  types.each { |t| own_noop_types << Composable.wrap(t) }
  self
end

.noop_typesObject

All registered pass-through types, inherited first.



68
# File 'lib/plumb/codec.rb', line 68

def noop_types = inherited_registry(:noop_types, own_noop_types)

.to_composableObject



78
# File 'lib/plumb/codec.rb', line 78

def to_composable = instance

.to_plumb_type(op:, left:) ⇒ Object



77
# File 'lib/plumb/codec.rb', line 77

def to_plumb_type(op:, left:) = instance.to_plumb_type(op:, left:)

.|(other) ⇒ Object



74
# File 'lib/plumb/codec.rb', line 74

def |(other) = instance | other

Instance Method Details

#>>(other) ⇒ Object

Decode direction: rewrite other so it accepts the encoders' input form and produces the output values other describes. The rewritten type REPLACES the composition — no codec node remains.



108
109
110
# File 'lib/plumb/codec.rb', line 108

def >>(other)
  Rewriter.new(self, :decode).call(Composable.wrap(other))
end

#at_path(path) ⇒ Object



203
# File 'lib/plumb/codec.rb', line 203

def at_path(path) = path.empty? ? 'the root type' : "field `#{path.join('.')}`"

#call(_result) ⇒ Object

Raises:



146
147
148
# File 'lib/plumb/codec.rb', line 146

def call(_result)
  raise Plumb::TypeError, "#{inspect} is not a runtime type; compose it with a type via #>>"
end

#encoder_for(type, path = BLANK_ARRAY) ⇒ Object

The best matching encoder for type, or nil. Matching is against each encoder's output type — schemas are written in output terms in both directions. Most-specific wins; equivalent types tie-break to the last registered; incomparable multi-matches raise.



154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
# File 'lib/plumb/codec.rb', line 154

def encoder_for(type, path = BLANK_ARRAY)
  matches = encoders.select { |e| Plumb::Subtyping.subtype?(type, e.output_type) }
  return nil if matches.empty?
  return matches.first if matches.size == 1

  minimal = matches.reject do |e|
    # e is dominated when another match's output type is strictly narrower.
    matches.any? do |o|
      !o.equal?(e) && Plumb::Subtyping.strict_subtype?(o.output_type, e.output_type)
    end
  end

  first = minimal.first
  unless minimal.all? { |e| Plumb::Subtyping.equivalent?(e.output_type, first.output_type) }
    raise Plumb::TypeError,
          "#{inspect}: #{at_path(path)} (#{type.inspect}) matches multiple incomparable encoders: " \
          "#{minimal.map(&:inspect).join(', ')}. Register a more specific encoder or restructure."
  end

  minimal.last
end

#for(type) ⇒ Array(Composable, Composable)

Both directions for a type, as a [decoding, encoding] pair:

decoder, encoder = Plumb::Codec::JSON.for(Person)
decoder.parse(input_data) # => a Person hash
encoder.parse(person)     # => output structures

Parameters:

Returns:



120
121
122
123
# File 'lib/plumb/codec.rb', line 120

def for(type)
  type = Composable.wrap(type)
  [self >> type, type >> self]
end

#noop?(type, direction) ⇒ Boolean

Is type (one side of it, per direction) covered by the registered noop types? Decode checks what the type ACCEPTS (it will be fed raw input data); encode checks what it PRODUCES (its output lands in the encoded document).

Returns:

  • (Boolean)


180
181
182
183
184
185
186
187
188
189
190
191
# File 'lib/plumb/codec.rb', line 180

def noop?(type, direction)
  return false unless @noop_union

  side = direction == :decode ? Plumb::Subtyping.accepted_type(type) : Plumb::Subtyping.resolved_output(type)
  # A pure filter with an opaque side is judged by the type itself: a
  # bare-matcher Constraint (a branch of a factored refinement union, eg.
  # the `String[/\Atrue\z/i] | String['1']` boolean input type) reports Any
  # as its accepted type, but as a value-preserving refinement it IS its
  # own honest description.
  side = type if side.is_a?(AnyClass) && Plumb::Subtyping.value_preserving?(type)
  Plumb::Subtyping.subtype?(side, @noop_union)
end

#noop_value?(value) ⇒ Boolean

Is this concrete VALUE covered by the noop types? Used for Static nodes, whose fixed value can be validated directly — subtyping over the node can't relate an atomic Static to a container noop (Static[[]] vs Types::Array), but the value itself can just be checked.

Returns:

  • (Boolean)


197
198
199
200
201
# File 'lib/plumb/codec.rb', line 197

def noop_value?(value)
  return false unless @noop_union

  @noop_union === value
end

#to_plumb_type(op:, left:) ⇒ Object

Encode direction, reached when the codec is the RIGHT operand (Person >> JSONCodec — see Composable#to_plumb_type): build an encode rewrite of what left produces. Composable#>> then composes And(left, rewrite): the left validates its input once, the rewrite encodes. Building from left's OUTPUT (not left itself) avoids re-running its coercions on already-parsed values.



131
132
133
134
135
136
137
138
139
140
141
# File 'lib/plumb/codec.rb', line 131

def to_plumb_type(op:, left:)
  unless op == :>>
    raise Plumb::TypeError, "#{inspect} only composes with #>> (got #{op}); a Codec is not a value type"
  end

  left = Composable.wrap(left)
  # A plain-include struct wraps as an opaque Step (output Any), so its
  # rewrite target is the struct node itself, not its resolved output.
  target = Plumb::Attributes.struct_class(left) ? left : Plumb::Subtyping.resolved_output(left)
  Rewriter.new(self, :encode).call(target)
end

#|(_other) ⇒ Object Also known as: &

Raises:



143
# File 'lib/plumb/codec.rb', line 143

def |(_other) = raise Plumb::TypeError, "#{inspect} only composes with #>>; a Codec is not a value type"