Class: Plumb::Codec
- Inherits:
-
Object
- Object
- Plumb::Codec
- 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.
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
-
.>>(other) ⇒ Object
Decode direction: rewrite
otherso it accepts the encoders' input form and produces the output valuesotherdescribes. - .at_path(path) ⇒ Object
- .call(_result) ⇒ Object
-
.encoder(*encoder_classes) ⇒ Object
Register one or more Encoder subclasses, in matching-priority order.
-
.encoder_for(type, path = BLANK_ARRAY) ⇒ Object
The best matching encoder for
type, or nil. -
.for(type) ⇒ Array(Composable, Composable)
Both directions for a type, as a [decoding, encoding] pair:.
-
.inherited(subclass) ⇒ Object
A subclass starts from a COPY of its parent's registries and appends its own, so
encodersreads inherited-first, own-last (later registrations win ties in matching). -
.inspect ⇒ Object
Named, so an anonymous
Class.new(Codec)still says what it carries. -
.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.
-
.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). -
.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[[]]vsTypes::Array), but the value itself can just be checked. -
.to_composable ⇒ Object
A Codec is not a value type, and has no node to stand in for one.
-
.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 whatleftproduces. - .|(_other) ⇒ Object (also: &)
Instance Method Summary collapse
- #decode(key, payload) ⇒ Object
- #encode(key, payload) ⇒ Object
- #freeze ⇒ Object
-
#initialize {|_self| ... } ⇒ Codec
constructor
A new instance of Codec.
- #inspect ⇒ Object
- #key?(key) ⇒ Boolean
-
#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.
Constructor Details
#initialize {|_self| ... } ⇒ Codec
Returns a new instance of Codec.
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
.encoders ⇒ Object
73 |
# File 'lib/plumb/codec.rb', line 73 def encoders = @encoders ||= [] |
.noop_types ⇒ Object
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
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)
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 |
.inspect ⇒ Object
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).
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.
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_composable ⇒ Object
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.
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 |
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) |
#freeze ⇒ Object
211 212 213 214 |
# File 'lib/plumb/codec.rb', line 211 def freeze @entries.freeze super end |
#inspect ⇒ Object
229 230 231 |
# File 'lib/plumb/codec.rb', line 229 def inspect %(#<#{self.class}:#{object_id} [#{@entries.size} entries]>) end |
#key?(key) ⇒ 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 |