Class: Plumb::Codec

Inherits:
Object
  • Object
show all
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, Entry, Forms, JSON, RangeEncoder, Rewriter, SymbolEncoder, TimeEncoder, URIEncoder

Constant Summary collapse

NoEntryError =
Class.new(KeyError)
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 =

Class Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize {|_self| ... } ⇒ Codec

Returns a new instance of Codec.

Yields:

  • (_self)

Yield Parameters:

  • _self (Plumb::Codec)

    the object that the method was called on



203
204
205
206
207
208
209
# File 'lib/plumb/codec.rb', line 203

def initialize(&)
  @entries = {}
  return unless block_given?

  yield self
  freeze
end

Class Attribute Details

.encodersObject



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

def encoders = @encoders ||= []

.noop_typesObject



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

def noop_types = @noop_types ||= []

Class 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.



84
85
86
# File 'lib/plumb/codec.rb', line 84

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

.at_path(path) ⇒ Object



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

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

.call(_result) ⇒ Object

Raises:



127
128
129
# File 'lib/plumb/codec.rb', line 127

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

.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.



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

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

    encoders << enc
  end
  self
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.



135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
# File 'lib/plumb/codec.rb', line 135

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:



96
97
98
99
# File 'lib/plumb/codec.rb', line 96

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

.inherited(subclass) ⇒ Object

A subclass starts from a COPY of its parent's registries and appends its own, so encoders reads inherited-first, own-last (later registrations win ties in matching). Copied at subclass time rather than resolved at read time because registration happens in a class body: a parent is complete before a subclass of it exists.



67
68
69
70
71
# File 'lib/plumb/codec.rb', line 67

def inherited(subclass)
  super
  subclass.encoders = encoders.dup
  subclass.noop_types = noop_types.dup
end

.inspectObject

Named, so an anonymous Class.new(Codec) still says what it carries.



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

def inspect = "#{name || superclass.name}[#{encoders.map(&:inspect).join(', ')}]"

.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.



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

def noop(*types)
  types.each { |t| noop_types << Composable.wrap(t) }
  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)


161
162
163
164
165
166
167
168
169
170
171
172
# File 'lib/plumb/codec.rb', line 161

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)


178
179
180
181
182
# File 'lib/plumb/codec.rb', line 178

def noop_value?(value)
  return false unless noop_union

  noop_union === value
end

.to_composableObject

A Codec is not a value type, and has no node to stand in for one. Both of these say so at the point of the mistake — without #to_composable, Composable.wrap would see a #call and build an opaque step out of the class.

Raises:



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

def to_composable = raise(Plumb::TypeError, "#{inspect} is not a type; compose it with a type via #>>")

.to_plumb_type(op:, left:) ⇒ Object

Encode direction, reached when the codec is the RIGHT operand (Person >> JSONCodec — see Composable.resolve_operand): 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.



107
108
109
110
111
112
113
114
115
116
117
# File 'lib/plumb/codec.rb', line 107

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:



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

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

Instance Method Details

#decode(key, payload) ⇒ Object



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

def decode(key, payload) = entry(key).decoder.parse(payload)

#encode(key, payload) ⇒ Object



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

def encode(key, payload) = entry(key).encoder.parse(payload)

#freezeObject



211
212
213
214
# File 'lib/plumb/codec.rb', line 211

def freeze
  @entries.freeze
  super
end

#inspectObject



229
230
231
# File 'lib/plumb/codec.rb', line 229

def inspect
  %(#<#{self.class}:#{object_id} [#{@entries.size} entries]>)
end

#key?(key) ⇒ Boolean

Returns:

  • (Boolean)


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

def key?(key) = @entries.key?(key)

#register(key, type = key) ⇒ Object

Named, not splatted: both sides are types that #parse, so a swapped pair would decode where it should encode without anything raising.



218
219
220
221
222
# File 'lib/plumb/codec.rb', line 218

def register(key, type = key)
  decoder, encoder = self.class.for(type)
  @entries[key] = Entry.new(decoder:, encoder:)
  self
end