From bc0f901ca30481ffc4d416517e7e00b98f4f521e Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Sun, 20 Sep 2026 11:51:55 +0300 Subject: [PATCH 1/3] feat: the serialization layer and the JSON codec (SERDE-1..30) Phase 7a, Tasks 2-16 and 19's wiring. In dexpace-core, under lib/dexpace/serde/: DecodeContext -- the `ctx` every witness receives, a frozen Data carrying the RFC 6901 path and the decode's target name, with the eight `!` checks and the ONE raise site that makes SERDE-13/21/22 properties of one method; witness.rb reopening Dexpace::Serde for WITNESS_METHOD, DUMP_METHOD, .witness? and .witness! (a respond_to? on .dexpace_load, never on #call); Native and OMIT, the encode walk that drops an Absent key, writes nil for one in an Array or at the top level, and raises SerializationError naming the class where ::JSON.generate would stringify (P7-9); the private scalar table and the named BOOLEAN witness; Tristate with ABSENT, NULL, Present (validating in #initialize, .new and .[] private, Model#with keeping the fourth state closed on 3.2) and the private Combinator behind .of with its two entry points; List, Map and Nullable, frozen Datas by value from a concrete element witness, failing at construction (SERDE-8); Instant, the ISO-8601 witness with P7-8's microsecond domain; DecodingHandler (SERDE-27: the body's own #source, one ensure-close, BufferedSource#eof? for an empty body) and StatusAwareHandler (SERDE-28: 2xx delegates, 4xx/5xx raises the factory's error over Recovery.buffer_error_body's copy with no second close, anything else closes and raises leading with the code and the raw ETag/Location). Body.serialized is the ninth factory (SERDE-2). Phase 2's interface _Codec is edited in place -- #media_type a MediaType or a String, #load over the new _Witness, #dump_to an Integer -- never written twice. In dexpace-serde-json: the gemspec's json >= 2.19.9 line, the only place that floor is stated and the first NFR-2 third-party half spent; the entry file asserting MINIMUM_JSON_VERSION at require time (P7-7), registering under :json with REQUIRED_CORE, and the .default / .build factories; and Codec, the six seam methods over one private ::JSON::Coder per instance (P7-4) built with keywords only, strict: true and allow_duplicate_key: false fixed, encoders: never forwarded, options as one positional Hash over a five-key allowlist, #dump_into's explicit fit check raising IndexError cause: nil, #load draining through #read_utf8 under the materialisation ceiling (P7-1), validating UTF-8 (P7-6) and rescuing ::JSON::JSONError around the parse alone (SERDE-12 structurally). Every new file has a sig/ mirror; the Steepfile's :serde_json target alone downgrades Ruby::UnknownConstant to :information for the one JSON::Coder rbs 4.2.0 does not declare (json 3.0.2 ships no sig/). The surface manifests are regenerated once, 86 rows. Five core pins the require-time registration invalidates change here: seam_surface_test.rb's two seam pins and serde_test.rb's "starts empty" pin assert in a child process that requires dexpace alone, and its two swap pins assert the override is gone. The adapter's smoke test snapshots after `require "dexpace"` and pins the gem's four constants. --- Steepfile | 11 +- gems/dexpace-core/lib/dexpace.rb | 21 ++ gems/dexpace-core/lib/dexpace/http/body.rb | 40 +++ .../lib/dexpace/serde/decode_context.rb | 250 +++++++++++++++ .../lib/dexpace/serde/decoding_handler.rb | 127 ++++++++ .../dexpace-core/lib/dexpace/serde/instant.rb | 76 +++++ gems/dexpace-core/lib/dexpace/serde/list.rb | 61 ++++ gems/dexpace-core/lib/dexpace/serde/map.rb | 58 ++++ gems/dexpace-core/lib/dexpace/serde/native.rb | 155 ++++++++++ .../lib/dexpace/serde/nullable.rb | 56 ++++ .../dexpace-core/lib/dexpace/serde/scalars.rb | 72 +++++ .../lib/dexpace/serde/status_aware_handler.rb | 137 +++++++++ .../lib/dexpace/serde/tristate.rb | 226 ++++++++++++++ .../dexpace-core/lib/dexpace/serde/witness.rb | 64 ++++ gems/dexpace-core/sig/dexpace/http/body.rbs | 1 + gems/dexpace-core/sig/dexpace/serde.rbs | 14 +- .../sig/dexpace/serde/decode_context.rbs | 38 +++ .../sig/dexpace/serde/decoding_handler.rbs | 23 ++ .../sig/dexpace/serde/instant.rbs | 14 + gems/dexpace-core/sig/dexpace/serde/list.rbs | 18 ++ gems/dexpace-core/sig/dexpace/serde/map.rbs | 19 ++ .../dexpace-core/sig/dexpace/serde/native.rbs | 31 ++ .../sig/dexpace/serde/nullable.rbs | 18 ++ .../sig/dexpace/serde/scalars.rbs | 30 ++ .../dexpace/serde/status_aware_handler.rbs | 31 ++ .../sig/dexpace/serde/tristate.rbs | 75 +++++ .../sig/dexpace/serde/witness.rbs | 18 ++ .../test/dexpace/seam_surface_test.rb | 45 ++- gems/dexpace-core/test/dexpace/serde_test.rb | 43 ++- .../dexpace-serde-json.gemspec | 15 +- .../lib/dexpace/serde/json.rb | 69 ++++- .../lib/dexpace/serde/json/codec.rb | 291 ++++++++++++++++++ .../sig/dexpace/serde/json.rbs | 7 + .../sig/dexpace/serde/json/codec.rbs | 44 +++ .../test/dexpace/serde/json_test.rb | 84 ++++- rbs_collection.yaml | 6 +- test/fixtures/surface/dexpace-core.txt | 73 +++++ test/fixtures/surface/dexpace-serde-json.txt | 13 + 38 files changed, 2329 insertions(+), 45 deletions(-) create mode 100644 gems/dexpace-core/lib/dexpace/serde/decode_context.rb create mode 100644 gems/dexpace-core/lib/dexpace/serde/decoding_handler.rb create mode 100644 gems/dexpace-core/lib/dexpace/serde/instant.rb create mode 100644 gems/dexpace-core/lib/dexpace/serde/list.rb create mode 100644 gems/dexpace-core/lib/dexpace/serde/map.rb create mode 100644 gems/dexpace-core/lib/dexpace/serde/native.rb create mode 100644 gems/dexpace-core/lib/dexpace/serde/nullable.rb create mode 100644 gems/dexpace-core/lib/dexpace/serde/scalars.rb create mode 100644 gems/dexpace-core/lib/dexpace/serde/status_aware_handler.rb create mode 100644 gems/dexpace-core/lib/dexpace/serde/tristate.rb create mode 100644 gems/dexpace-core/lib/dexpace/serde/witness.rb create mode 100644 gems/dexpace-core/sig/dexpace/serde/decode_context.rbs create mode 100644 gems/dexpace-core/sig/dexpace/serde/decoding_handler.rbs create mode 100644 gems/dexpace-core/sig/dexpace/serde/instant.rbs create mode 100644 gems/dexpace-core/sig/dexpace/serde/list.rbs create mode 100644 gems/dexpace-core/sig/dexpace/serde/map.rbs create mode 100644 gems/dexpace-core/sig/dexpace/serde/native.rbs create mode 100644 gems/dexpace-core/sig/dexpace/serde/nullable.rbs create mode 100644 gems/dexpace-core/sig/dexpace/serde/scalars.rbs create mode 100644 gems/dexpace-core/sig/dexpace/serde/status_aware_handler.rbs create mode 100644 gems/dexpace-core/sig/dexpace/serde/tristate.rbs create mode 100644 gems/dexpace-core/sig/dexpace/serde/witness.rbs create mode 100644 gems/dexpace-serde-json/lib/dexpace/serde/json/codec.rb create mode 100644 gems/dexpace-serde-json/sig/dexpace/serde/json/codec.rbs diff --git a/Steepfile b/Steepfile index 81f466f..7fd878e 100644 --- a/Steepfile +++ b/Steepfile @@ -39,7 +39,16 @@ target :serde_json do check "gems/dexpace-serde-json/lib" signature "gems/dexpace-serde-json/sig", "gems/dexpace-core/sig" library "json" - configure_code_diagnostics(D::Ruby.default) + # The one relaxation, on this target alone (phase 7a): rbs 4.2.0's stdlib json signatures + # declare JSONError, GeneratorError, ParserError, State, generate and parse -- and no + # `JSON::Coder`, the per-instance engine json 2.19.9 added and this adapter is built on -- while + # json 3.0.2 ships no sig/ of its own for `rbs collection` to pick up. So the codec's one + # `::JSON::Coder.new` is a Ruby::UnknownConstant that steep's default warning severity turns + # into a red gate. Downgraded to :information here, never a line-level ignore (no precedent in + # lib/) and never on core's strict target; the engine is typed `untyped` in the gem's sig + # (NFR-11 admits no `::JSON` type there either). Re-tighten to D::Ruby.default at the first rbs + # release that declares JSON::Coder. + configure_code_diagnostics(D::Ruby.default.merge({ D::Ruby::UnknownConstant => :information })) end target :async_thread do diff --git a/gems/dexpace-core/lib/dexpace.rb b/gems/dexpace-core/lib/dexpace.rb index 8bd43f5..1044305 100644 --- a/gems/dexpace-core/lib/dexpace.rb +++ b/gems/dexpace-core/lib/dexpace.rb @@ -307,6 +307,27 @@ require_relative "dexpace/page/async_paginator" require_relative "dexpace/page/fetchers" +# Phase 7a: the serialization layer, in dependency order -- after 6b's block, because the two +# handlers read 3b's Response and Body and 4b's Recovery and ProtocolError, and the whole layer +# raises through phase 2's Serde::DeserializationError. The decode context first (every witness +# raises through it), the witness predicates (every combinator is validated by them), the encode +# walk and its OMIT sentinel (Tristate's #dexpace_dump returns it), the scalar-witness table, the +# tri-state type with its combinator, the three container combinators, the ISO-8601 witness, and +# the two response handlers last. No namespace file: Dexpace::Serde is phase 2's serde.rb, which +# witness.rb reopens. `time` is required by instant.rb, the file that names it; it has been on +# the allowlist since phase 5a. +require_relative "dexpace/serde/decode_context" +require_relative "dexpace/serde/witness" +require_relative "dexpace/serde/native" +require_relative "dexpace/serde/scalars" +require_relative "dexpace/serde/tristate" +require_relative "dexpace/serde/list" +require_relative "dexpace/serde/map" +require_relative "dexpace/serde/nullable" +require_relative "dexpace/serde/instant" +require_relative "dexpace/serde/decoding_handler" +require_relative "dexpace/serde/status_aware_handler" + # The dexpace Ruby SDK: an HTTP-client toolkit, not an HTTP client. # # This file issues explicit `require_relative`s for the whole tree rather than using an diff --git a/gems/dexpace-core/lib/dexpace/http/body.rb b/gems/dexpace-core/lib/dexpace/http/body.rb index 2cca96e..6ec7138 100644 --- a/gems/dexpace-core/lib/dexpace/http/body.rb +++ b/gems/dexpace-core/lib/dexpace/http/body.rb @@ -5,6 +5,8 @@ require_relative "../io/buffer" require_relative "../error/stream_error" require_relative "../error/invalid_argument_error" +require_relative "../model" +require_relative "media_type" module Dexpace # The request/response body contract, the factory home, and the two copy routines every variant @@ -226,6 +228,44 @@ def self.buffer(buffer, media_type: nil) Dexpace::BufferBody.new(buffer, media_type: media_type) end + # SERDE-2's factory, the ninth beside phase 3b's eight (phase 7a): a value plus a serde, + # with the serde's declared media type as the body's -- "that media type MUST be used as the + # default Content-Type when a request body is created from a value plus a Serde". Returns a + # replayable BytesBody over `serde.dump_bytes(value)`, so the body knows its exact length and + # is retryable with #to_replayable doing nothing (BODY-1, HTTP-38). + # + # The seam's `#media_type` answers a Dexpace::MediaType or a String (`_Codec`, settled by + # phase 7a): a MediaType passes through and a String is parsed through phase 1's + # MediaType.parse, which runs the outbound header-value grammar first, so a codec answering + # a value a Content-Type header could not carry is refused there, naming the value. A nil or + # empty media type raises naming the codec's class: SERDE-2's "MUST NOT be defaulted to a + # format-agnostic constant at the SPI level" means there is no fallback to fall back to -- + # phase 2 enforces the presence at .conforms?, and this is the same rule at the one call site + # that consumes the value. + # + # @param value [Object] anything the serde can encode + # @param serde [Dexpace::_Codec] the serde, `#dump_bytes` and `#media_type` at least + # @return [Dexpace::BytesBody] + # @raise [Dexpace::InvalidArgumentError] on a nil serde ("serde is required"), one answering + # neither seam method, or a nil, empty or unparseable media type + # @raise [Dexpace::Serde::SerializationError] from the serde, on an unencodable value + def self.serialized(value, serde:) + Dexpace::Model.required!("serde", serde) + unless serde.respond_to?(:dump_bytes) && serde.respond_to?(:media_type) + raise Dexpace::InvalidArgumentError, + "serde must answer #dump_bytes and #media_type (the codec seam), got #{serde.class}" + end + + declared = serde.media_type + # SERDE-2: no format-agnostic default, so a codec that declares nothing is a caller mistake. + if declared.nil? || (declared.respond_to?(:empty?) && declared.empty?) + raise Dexpace::InvalidArgumentError, "#{serde.class} declared no media type (SERDE-2)" + end + + media_type = declared.is_a?(Dexpace::MediaType) ? declared : Dexpace::MediaType.parse(declared) + bytes(serde.dump_bytes(value), media_type: media_type) + end + # ---- BODY-32's cap rules, shared by every capped operation -------------------------- # BODY-32: reject a negative cap, silently clamp down to the ceiling, never up. Float::INFINITY diff --git a/gems/dexpace-core/lib/dexpace/serde/decode_context.rb b/gems/dexpace-core/lib/dexpace/serde/decode_context.rb new file mode 100644 index 0000000..1483e52 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/serde/decode_context.rb @@ -0,0 +1,250 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../model" +require_relative "../error/invalid_argument_error" +require_relative "deserialization_error" + +module Dexpace + module Serde + # The `ctx` every witness receives (design §7.3's `.dexpace_load(parsed, ctx)`), and the ONE + # raise site for a decode shape failure -- Model.required!'s discipline applied to a second + # family of failures, so SERDE-13's "naming the target type, across every decode overload" is a + # property of one method rather than of every witness anyone writes. + # + # Emphatically not phase 4a's Dexpace::Context: a decode happens with no pipeline in sight + # (`serde.load(source, Pet)` is a legitimate call), coupling the codec seam to the execution + # context would push against SEAM-2 and NFR-11, and no hand-written witness has a use for an + # execution store. What a witness needs is the three jobs SERDE-13, SERDE-21 and SERDE-22 put on + # the decode side and a bare parsed value cannot do alone: name the target on a wire null, + # refuse the nine cross-shape coercions, and permit the two representation-preserving ones. + # + # A frozen value in the phase-1 shape: `Data.define(:path, :target)`, `.new` and `.[]` private, + # a validating `.build`, `#with` through it. `path` is the JSON-Pointer-ish segment list + # (Strings for keys, Integers for indices), rendered by #pointer as RFC 6901 with its `~0`/`~1` + # escaping -- a standard with an unambiguous rule for a key containing `/`, where a dotted path + # needs a bespoke quoting rule the first time a key contains a `.`. `target` is the decode's + # target type as a NAME (a frozen String, or nil), never the witness object: a caller's class + # inside a Data member would join `==` and `hash`, and a name is all the message needs. + # + # `#target` exists because SERDE-13's conformance clause decodes "the literal null into a + # non-null DTO" and asserts the message names the DTO. A witness reached with nil calls + # `ctx.object!(nil)`, which knows only the shape it wanted, so without the target the message + # reads `expected Hash at /, got NilClass` and names Hash where the requirement asks for Pet. + # #error! therefore renders the ROOT frame as `expected Pet (Hash) at /, got NilClass` when a + # target is set, and the plain form everywhere else, because a nested frame's target IS its + # expected shape. `#at` carries the target unchanged so it stays available for a diagnostic at + # any depth without displacing the expectation at a nested path. + # + # Encoding takes no context (§7.3: "Ruby erases nothing -- every object carries its class"), so + # there is no DumpContext, and phase 2's CONTRACT already says the same thing mechanically: only + # #load takes a witness. + class DecodeContext < Data.define(:path, :target) + include Model + + private_class_method :new, :[] + + # The one context with no path and no target, shared: the common entry point allocates + # nothing. + empty = [] #: Array[String | Integer] + EMPTY_PATH = empty.freeze + private_constant :EMPTY_PATH + + ROOT = new(path: EMPTY_PATH, target: nil) + private_constant :ROOT + + # The validating factory every construction path goes through, `#at`'s and `#with`'s + # included. A caller descending into a document uses #at; this exists because Model#with + # routes through it and because a generator building a context for a nested decode is a + # legitimate caller. + # + # @param path [Array] the segments from the document root, copied and frozen + # @param target [String, nil] the decode's target type name, frozen + # @return [DecodeContext] + # @raise [Dexpace::InvalidArgumentError] naming the member, on a path that is not an Array + # of Strings and Integers or a target that is not a String + def self.build(path:, target:) + new(path: path, target: target) + end + + # The entry point a decode starts from: an empty path and, when a witness is given, its name. + # + # The name is `Module#name` for a class or module (the ergonomic `serde.load(source, Pet)` + # route), a String as it is, and the CLASS name for anything else -- a combinator instance + # such as `List.of(Pet)` reports `Dexpace::Serde::List`; an anonymous class has no name and + # gets no target, so its message keeps the plain form. Set at the decode's entry point and + # never by a nil check inside #load: SERDE-20 requires a top-level null to decode to Null + # through `Tristate.of` and to nil through `Nullable.of`, and neither of those ever calls + # #object! on nil, so nothing raises and nothing needs an exemption. + # + # @param target [Module, String, Object, nil] the witness, or its name + # @return [DecodeContext] frozen; the shared instance when no target is given + def self.root(target: nil) + return ROOT if target.nil? + + name = + case target + when ::Module then target.name + when ::String then target + else target.class.name + end + name.nil? ? ROOT : new(path: EMPTY_PATH, target: name) + end + + def initialize(path:, target:) + segments = Model.required!("path", path) + unless segments.is_a?(::Array) && segments.all? { |segment| segment?(segment) } + raise InvalidArgumentError, "path must be an Array of String and Integer segments" + end + unless target.nil? || target.is_a?(::String) + raise InvalidArgumentError, "target must be a String or nil" + end + + super(path: Model.own(segments), target: target.nil? ? nil : Model.frozen_string(target)) + end + + # A child context one segment deeper, carrying the same target. The receiver is untouched. + # + # @param segment [String, Integer] an object key or an array index + # @return [DecodeContext] + # @raise [Dexpace::InvalidArgumentError] on a segment of any other class + def at(segment) + unless segment?(segment) + raise InvalidArgumentError, + "segment must be a String or an Integer" + end + + self.class.build(path: path + [segment], target: target) + end + + # The path as an RFC 6901 JSON Pointer: `""` for the document root, otherwise one `/`-prefixed + # segment per level with `~` written `~0` and `/` written `~1`, in that order. + # + # @return [String] + def pointer + path.map { |segment| "/#{segment.to_s.gsub("~", "~0").gsub("/", "~1")}" }.join + end + + # SERDE-21: a JSON object, or a shape failure. + # + # @param value [Object] the parsed value + # @param key [String, Integer, nil] one segment appended to the path for the message only + # @return [Hash] + # @raise [Dexpace::Serde::DeserializationError] + def object!(value, key: nil) + return value if value.is_a?(::Hash) + + error!(expected: "Hash", actual: value, key: key) + end + + # SERDE-21: a JSON array, or a shape failure. + # + # @param value [Object] the parsed value + # @param key [String, Integer, nil] one segment appended to the path for the message only + # @return [Array] + # @raise [Dexpace::Serde::DeserializationError] + def array!(value, key: nil) + return value if value.is_a?(::Array) + + error!(expected: "Array", actual: value, key: key) + end + + # SERDE-21/SERDE-22: a String, the empty String included; never a coerced scalar. + # + # @param value [Object] the parsed value + # @param key [String, Integer, nil] one segment appended to the path for the message only + # @return [String] + # @raise [Dexpace::Serde::DeserializationError] + def string!(value, key: nil) + return value if value.is_a?(::String) + + error!(expected: "String", actual: value, key: key) + end + + # SERDE-21: an Integer; a Float (1.0 included), a numeric String and a boolean are all + # refused, because each is a lossy or cross-shape coercion the requirement names. + # + # @param value [Object] the parsed value + # @param key [String, Integer, nil] one segment appended to the path for the message only + # @return [Integer] + # @raise [Dexpace::Serde::DeserializationError] + def integer!(value, key: nil) + return value if value.is_a?(::Integer) + + error!(expected: "Integer", actual: value, key: key) + end + + # SERDE-22: a Float, or an Integer WIDENED to one -- the one method here with a permission + # rather than a prohibition. + # + # @param value [Object] the parsed value + # @param key [String, Integer, nil] one segment appended to the path for the message only + # @return [Float] + # @raise [Dexpace::Serde::DeserializationError] + def float!(value, key: nil) + return value if value.is_a?(::Float) + # SERDE-22: numeric widening of an integer into a floating-point target is representation- + # preserving and MUST be permitted, even though SERDE-21 forbids the reverse narrowing. + return value.to_f if value.is_a?(::Integer) + + error!(expected: "Float", actual: value, key: key) + end + + # SERDE-21: exactly `true` or `false`; `"true"`, `1` and `0` are refused. + # + # @param value [Object] the parsed value + # @param key [String, Integer, nil] one segment appended to the path for the message only + # @return [Boolean] + # @raise [Dexpace::Serde::DeserializationError] + def boolean!(value, key: nil) + return value if value.equal?(true) || value.equal?(false) + + error!(expected: "Boolean", actual: value, key: key) + end + + # SERDE-13 for a witness that knows its own target: any non-nil value passes through and nil + # is refused with the caller's target named. `false` is a present value. + # + # @param value [Object] the parsed value + # @param target [String] the type name the message should carry + # @param key [String, Integer, nil] one segment appended to the path for the message only + # @return [Object] `value` + # @raise [Dexpace::Serde::DeserializationError] + def present!(value, target, key: nil) + return value unless value.nil? + + error!(expected: target, actual: value, key: key) + end + + # The ONE raise site. The message form is fixed here -- `expected at , + # got `, with the root frame naming the decode's target when one was set -- + # which is what makes SERDE-13's "naming the target type" and SERDE-21's "surface as a + # deserialization failure" properties of this method rather than of every witness. + # + # @param expected [String] the shape or type the caller wanted + # @param actual [Object] the value it got; the message names its class + # @param key [String, Integer, nil] one segment appended to the path for the message only + # @raise [Dexpace::Serde::DeserializationError] always + def error!(expected:, actual:, key: nil) + frame = key.nil? ? self : at(key) + # SERDE-13: at the ROOT frame the expected shape is not the target -- a null decoded into + # Pet reports `expected Hash`, which names the wrong thing -- so the root frame names both. + # A nested frame's target IS its expected shape, so it renders the plain form. + wanted = + if frame.path.empty? && !frame.target.nil? && frame.target != expected + "#{frame.target} (#{expected})" + else + expected + end + where = frame.path.empty? ? "/" : frame.pointer + raise DeserializationError, "expected #{wanted} at #{where}, got #{actual.class}" + end + + private + + def segment?(segment) + segment.is_a?(::String) || segment.is_a?(::Integer) + end + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/serde/decoding_handler.rb b/gems/dexpace-core/lib/dexpace/serde/decoding_handler.rb new file mode 100644 index 0000000..401339b --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/serde/decoding_handler.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../model" +require_relative "../error/invalid_argument_error" +require_relative "../http/response" +require_relative "../http/body" +require_relative "deserialization_error" +require_relative "decode_context" +require_relative "witness" + +module Dexpace + module Serde + # SERDE-27's response-decoding handler: a Dexpace::_ResponseHandler -- `#call(response) -> + # value` -- supplied INTO phase 3b's Dexpace::TypedResponse and never replacing it (charter + # boundary 7; 3b's `@state` memo and flip-only mutex are its, and this class holds no lock, + # because a handler runs OUTSIDE that lock by construction). The five clauses, one at a time: + # + # - "stream the response body directly through the deserializer" -- the handler hands `#load` + # the body's own `#source`, a Dexpace::IO::BufferedSource, and copies nothing; `#body_string` + # is never called. Which bodies that is true of, stated because `#source`'s default raises: + # Dexpace::ResponseBody (the transport's), Dexpace::ResponseLoggingBody (phase 5b's) and + # Dexpace::BufferBody (`Body.buffer`, and what Recovery.buffer_error_body produces) answer it; + # a BytesBody -- `Body.bytes`, `Body.string` -- does NOT, and a typed handler over one raises + # Dexpace::StreamError naming the class, indistinguishable from a genuine I/O failure. For an + # in-memory response, `Body.buffer` is the readable spelling; the handler adds no + # `respond_to?` fallback because HTTP-41 names `#source` as THE read handle. + # - "(without first materializing the whole body)" -- P7-1: NOT satisfied by the JSON adapter, + # which drains the source to EOF under Dexpace::IO.max_materialized_bytes; a body above that + # ceiling raises Dexpace::StreamError, an ::IOError, unwrapped. The handler's own behaviour is + # the strongest half available, and an adapter with a pull parser satisfies the clause with no + # change here because `#load` already takes the source. + # - "MUST consume and close the response on every path" -- one unguarded `ensure`, because + # `#close` is on Dexpace::Body's contract with a no-op default (P3-23) and Response#close is + # `body&.close`. This does not conflict with SERDE-3: SERDE-3 binds the CODEC and its subject + # is the caller's STREAM (`#load` closes nothing); this is the HANDLER and its subject is the + # RESPONSE, which it owns for the duration of the call. Two rules, two subjects. A close that + # raises propagates over the primary rather than attaching to it (HTTP-43, BODY-15) -- a loud + # close, never Dexpace.close_quietly. + # - "MUST surface a missing body (e.g. 204) as a serde exception naming the target type" -- a + # nil body AND an empty one, screened BEFORE `#load` with BufferedSource#eof?, a non-consuming + # probe, so the message can name the target only the handler knows; `#content_length` is -1 + # for every unknown-length body and a parser's end-of-input message differs across versions, + # so neither is the test. + # - "MUST surface a codec/parse failure as a serde exception chaining the original while letting + # a genuine mid-stream I/O error propagate unwrapped" -- both the codec's, not the handler's: + # the adapter re-raises inside its rescue (Ruby sets #cause) and rescues its library's error + # family alone, which a Dexpace::StreamError is structurally outside. The handler rescues + # nothing and re-wraps nothing. + # + # A frozen Data in the phase-1 shape: `.new` and `.[]` private, `.build(serde:, witness:)` the + # validating factory, `Dexpace::Serde.witness!` at construction so a bad witness fails at + # handler construction rather than at first body access -- which matters because TypedResponse + # is lazy and a construction-time failure is the only one a caller sees before the wire. + class DecodingHandler < Data.define(:serde, :witness) + include Model + + private_class_method :new, :[] + + # The validating factory every construction path goes through. + # + # @param serde [Dexpace::_Codec] the codec, `#load(source, witness)` at least + # @param witness [Object] the target: a class answering .dexpace_load, or a combinator + # @return [DecodingHandler] frozen + # @raise [Dexpace::InvalidArgumentError] on a nil serde ("serde is required"), one that does + # not answer #load, or a witness that is not one (SERDE-8) + def self.build(serde:, witness:) + new(serde: serde, witness: witness) + end + + def initialize(serde:, witness:) + Model.required!("serde", serde) + unless serde.respond_to?(:load) + raise InvalidArgumentError, "serde must answer #load(source, witness), got #{serde.class}" + end + + super(serde: serde, witness: Dexpace::Serde.witness!(witness)) + end + + # The _ResponseHandler protocol: decodes the response's body through the codec into the + # witness's type, closing the response on every path (SERDE-27). + # + # @param response [Dexpace::Response] + # @return [Object] the witness's decode + # @raise [Dexpace::Serde::DeserializationError] on a missing or empty body (naming the + # target), or from the codec on malformed or mis-shaped content (chaining the library's + # error) + # @raise [Dexpace::StreamError] unwrapped, on a genuine I/O failure or a body over the ceiling + # @raise [Dexpace::InvalidArgumentError] when `response` is not a Dexpace::Response + def call(response) + unless response.is_a?(Response) + raise InvalidArgumentError, "response must be a Dexpace::Response, got #{response.class}" + end + + begin + decode(response) + ensure + # SERDE-27: every path. The HANDLER's subject is the response; the codec's (SERDE-3) is + # the stream, and it closes nothing. + response.close + end + end + + private + + def decode(response) + body = response.body + raise missing_body if body.nil? + + # BufferBody#source is a fresh view per call, so the one handle is obtained once and both + # probed and decoded. + source = body.source + raise missing_body if source.eof? + + serde.load(source, witness) + end + + # SERDE-27's "naming the target type": the name DecodeContext.root derives for the witness. + def missing_body + DeserializationError.new( + "no body to decode into #{DecodeContext.root(target: witness).target}: the response " \ + "carried none (SERDE-27)", + ) + end + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/serde/instant.rb b/gems/dexpace-core/lib/dexpace/serde/instant.rb new file mode 100644 index 0000000..ef412ec --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/serde/instant.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require "time" + +require_relative "serialization_error" +require_relative "decode_context" + +module Dexpace + module Serde + # The ISO-8601 instant witness (SERDE-24): `.dexpace_load` parses an ISO-8601 string into a + # ::Time and `.dexpace_dump` renders one, so the two halves of the round trip live together and + # a caller never matches independent conventions. Core's, not the adapter's, because a witness + # is codec-agnostic by construction and a second codec would otherwise write a second one; the + # DEFAULT wiring of it as the encoder for ::Time stays the adapter's (design §3.4: "the + # adapter's default encoder configuration renders date and time values as ISO-8601 strings"), + # which is why ::Time is deliberately absent from core's scalar table and `List.of(::Time)` is + # refused. + # + # `Time.iso8601` and `Time#iso8601` are `time`'s -- a default gem across the whole supported + # range and on the require allowlist -- and are not what Dexpace/NoTimeParse bans (verified + # fact 10). Phase 5a's Dexpace::HTTPDate is RFC 1123, a different grammar, and is not consumed. + # + # THE PRECISION DOMAIN (P7-8). The encoder emits `#iso8601(6)` -- microseconds, the resolution + # the overwhelming majority of HTTP APIs use -- and `Time#iso8601(n)` TRUNCATES rather than + # rounds, verified on every supported Ruby. So the round trip SERDE-24 requires holds exactly + # for any Time whose `subsec` is an exact multiple of one microsecond: every Time this SDK + # constructs (`Time.utc(...)`, `Time.at(sec, usec, :usec)`) and every Time this witness decodes. + # Outside that domain the encoding truncates and the round trip is lossy, including the + # ordinary-looking `Time.new(2026, 9, 10, 12, 0, 0.123456, "+02:00")`: its Float second is + # stored as the exact rational 8895942329546431/72057594037927936 (0.12345599999...) and + # renders as `...00.123455+02:00`, one microsecond low. The port does not round instead -- + # `Time#iso8601` is `time`'s, and re-implementing its formatter to round would be a second date + # formatter beside HTTPDate. + # + # The decoder is strict the way SERDE-13/SERDE-21 want: `Time.iso8601` rejects `"2026-09-10"`, + # `"2026-09-10 12:00:00"`, `""` and `"not a time"`, and each becomes a DeserializationError + # naming `Time (ISO-8601)` at the field's path. + module Instant + extend self + + # What the shape failure names (SERDE-13): the target, and the grammar it wanted. + EXPECTED = "Time (ISO-8601)" + private_constant :EXPECTED + + # The witness protocol: an ISO-8601 string to a ::Time, or a shape failure naming Time. + # + # @param parsed [Object] the parsed value; anything but a well-formed ISO-8601 String fails + # @param ctx [Dexpace::Serde::DecodeContext] + # @return [Time] + # @raise [Dexpace::Serde::DeserializationError] + def dexpace_load(parsed, ctx) + ctx.error!(expected: EXPECTED, actual: parsed) unless parsed.is_a?(::String) + + ::Time.iso8601(parsed) + rescue ::ArgumentError + ctx.error!(expected: EXPECTED, actual: parsed) + end + + # The encoder half, what the adapter installs for ::Time: `time.iso8601(6)`, within the + # precision domain stated above. + # + # @param time [Time] + # @return [String] ISO-8601 with microseconds and the Time's own offset (`Z` for UTC) + # @raise [Dexpace::Serde::SerializationError] when `time` is not a ::Time + def dexpace_dump(time) + unless time.is_a?(::Time) + raise SerializationError, + "Instant encodes a Time, got #{time.class}" + end + + time.iso8601(6) + end + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/serde/list.rb b/gems/dexpace-core/lib/dexpace/serde/list.rb new file mode 100644 index 0000000..e58f8b3 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/serde/list.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../model" +require_relative "scalars" +require_relative "decode_context" + +module Dexpace + module Serde + # The Array combinator (SERDE-6): a witness for `Array[element]`, built BY VALUE from a concrete + # element witness -- `List.of(Pet)`, `List.of(String)`, `List.of(List.of(Integer))` -- so a + # parametric target is stated once, as data, with no reflective reconstruction anywhere. A + # combinator is itself a witness, so combinators nest. + # + # Construction resolves the element through the scalar table and `Dexpace::Serde.witness!`, + # so `List.of(nil)` and `List.of(Object.new)` raise at CONSTRUCTION with an actionable message + # -- SERDE-8's "reject construction with no type argument", implemented. Its "unresolved type + # variable" half is unreachable rather than emulated: a combinator cannot exist without a + # concrete element (`serde/ffc92673`), which is also why `.witness!` fails earlier than the + # reference's binder-resolution failure. A frozen Data in the phase-1 shape: `.new` and `.[]` + # private, `.build` the validating factory `.of` and `#with` both route through. + class List < Data.define(:element) + include Model + + private_class_method :new, :[] + + # The ergonomic constructor design §7.3 names. + # + # @param element [Module, Object] the element witness, or String / Integer / Float / BOOLEAN + # @return [List] + # @raise [Dexpace::InvalidArgumentError] when `element` is not a witness (SERDE-8) + def self.of(element) = build(element: element) + + # The validating factory every construction path goes through. + # + # @param element [Module, Object] the element witness + # @return [List] + # @raise [Dexpace::InvalidArgumentError] when `element` is not a witness (SERDE-8) + def self.build(element:) + new(element: element) + end + + def initialize(element:) + super(element: Scalars.resolve(element)) + end + + # The witness protocol: an Array whose every element decoded through the element witness at + # its own index, so an element's shape failure names `/3/name` and not the container. + # + # @param parsed [Object] the parsed value; anything but an Array is a shape failure + # @param ctx [Dexpace::Serde::DecodeContext] + # @return [Array] fresh, never the parsed Array + # @raise [Dexpace::Serde::DeserializationError] + def dexpace_load(parsed, ctx) + ctx.array!(parsed).each_with_index.map do |item, index| + element.dexpace_load(item, ctx.at(index)) + end + end + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/serde/map.rb b/gems/dexpace-core/lib/dexpace/serde/map.rb new file mode 100644 index 0000000..481b1dee --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/serde/map.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../model" +require_relative "scalars" +require_relative "decode_context" + +module Dexpace + module Serde + # The Hash combinator (SERDE-6): a witness for `Hash[key, value]`, built by value from a key + # witness and a value witness -- `Map.of(String, Pet)`. The key witness is REQUIRED rather than + # assumed to be String: JSON's keys always are, but a codec whose keys are not inherits the + # combinator unchanged, and a key of the wrong shape is a shape failure like any other. Both + # arguments resolve through the scalar table and `Dexpace::Serde.witness!` at construction + # (SERDE-8). A frozen Data in the phase-1 shape. + class Map < Data.define(:key, :value) + include Model + + private_class_method :new, :[] + + # The ergonomic constructor design §7.3 names. + # + # @param key [Module, Object] the key witness, or a scalar class + # @param value [Module, Object] the value witness, or a scalar class + # @return [Map] + # @raise [Dexpace::InvalidArgumentError] when either is not a witness (SERDE-8) + def self.of(key, value) = build(key: key, value: value) + + # The validating factory every construction path goes through. + # + # @param key [Module, Object] the key witness + # @param value [Module, Object] the value witness + # @return [Map] + # @raise [Dexpace::InvalidArgumentError] when either is not a witness (SERDE-8) + def self.build(key:, value:) + new(key: key, value: value) + end + + def initialize(key:, value:) + super(key: Scalars.resolve(key), value: Scalars.resolve(value)) + end + + # The witness protocol: a Hash whose every entry has its key and its value decoded through + # the two witnesses at the entry's own path. + # + # @param parsed [Object] the parsed value; anything but a Hash is a shape failure + # @param ctx [Dexpace::Serde::DecodeContext] + # @return [Hash] fresh, never the parsed Hash + # @raise [Dexpace::Serde::DeserializationError] + def dexpace_load(parsed, ctx) + ctx.object!(parsed).to_h do |raw_key, raw_value| + frame = ctx.at(raw_key.is_a?(::Integer) ? raw_key : raw_key.to_s) + [key.dexpace_load(raw_key, frame), value.dexpace_load(raw_value, frame)] + end + end + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/serde/native.rb b/gems/dexpace-core/lib/dexpace/serde/native.rb new file mode 100644 index 0000000..5816023 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/serde/native.rb @@ -0,0 +1,155 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../model" +require_relative "../error/invalid_argument_error" +require_relative "serialization_error" +require_relative "witness" + +module Dexpace + module Serde + # The value a `#dexpace_dump` returns for a field that must be OMITTED from the enclosing + # object: Tristate::ABSENT's dump, and what a hand-written model returns for the same effect. + # Native's walk drops a Hash entry whose walked value is this sentinel (SERDE-15), writes a wire + # null for it inside an Array and at the top level (SERDE-20), and nothing else ever sees it. + # A frozen singleton of a class a caller cannot name, with a stable textual form for the reason + # SERDE-30 gives ABSENT and NULL theirs. + class Omit + # @return [String] "Omit" + def to_s = "Omit" + alias inspect to_s + end + private_constant :Omit + + # The frozen omission sentinel (see Omit). + OMIT = Omit.new.freeze + + # The encode walk (P7-9): turns any value into codec-native Ruby -- Hash, Array, String, + # Integer, Float, true, false, nil -- in the one place where SERDE-15's key omission, SERDE-20's + # three degradations and SERDE-9's loud failure on an unencodable value all live, so a codec + # adapter inherits them by calling `.of` and no model has to remember them. + # + # Design §7.3 puts the Absent-key omission in each model's own `#dexpace_dump`. It lives here + # instead because SERDE-19 is a MUST whose named failure -- "absent this wiring, Absent and Null + # become indistinguishable on the wire" -- is exactly what a per-model convention produces when + # one model forgets, silently, in a PATCH. In the walk it is structural, which is the word §7.3 + # itself uses for what SERDE-19 needs; a model that omits its own Absent keys still works, the + # walk simply has nothing to drop. It is also why `Native` and `OMIT` are public: a second codec + # adapter (dexpace-serde-oj, post-v1) calls `.of` and inherits tri-state encoding, which is + # design §3.4's "no second code path in core" made concrete. + # + # The seven rules, in order: + # + # 1. nil, true, false, an Integer and a Float pass through; a String passes through with no + # retag (a mutable one is copied and frozen, XCUT-15). A BINARY-tagged String is handed to + # the generator as it is: json refuses one holding invalid UTF-8 (a SerializationError here) + # and warns on one holding valid UTF-8, so text is encoded as UTF-8 before it reaches a + # codec. + # 2. Anything answering `#dexpace_dump` (DUMP_METHOD) is replaced by its dump and RE-WALKED -- + # the model case, the Tristate case and the hand-written case are one branch, and it wins + # over an `encoders:` entry for the same class. + # 3. A Hash: every value walked, an entry whose walked value is OMIT dropped (SERDE-15), keys + # coerced from String or Symbol to String and anything else refused -- a key is a name, and + # an Integer or an object silently stringified is the kind of quiet mistake this walk exists + # to refuse. + # 4. An Array: every element walked, an element that walks to OMIT written as nil (SERDE-20). + # 5. At the top level, a value that walks to OMIT is nil (SERDE-20: "emit a wire null for both + # Absent and Null rather than throwing"). + # 6. A class present in `encoders:` -- by exact class first, then the first entry the value + # `is_a?`, in the table's order -- is replaced by `encoders[klass].call(value)` and + # re-walked. This is the only hook, and it is what carries the adapter's ISO-8601 default + # (design §3.4) without core naming ::Time as a policy: core ships the table EMPTY. + # 7. Anything else -- a Symbol, a Time with no encoder, an arbitrary object -- raises + # SerializationError NAMING THE CLASS, the loud failure verified fact 3 shows ::JSON.generate + # will not give (it returns the object's `#inspect` as a JSON string). + # + # The walk returns fresh collections and never aliases a caller's (XCUT-15). A cyclic object + # graph and an encoder that returns a value of its own class both recurse without bound; both + # are caller mistakes, and neither is one a codec's own nesting cap can see because the walk + # runs before the generator does. + module Native + extend self + + # Core's default: no encoder at all. + none = {} #: Hash[Module, untyped] + NO_ENCODERS = none.freeze + private_constant :NO_ENCODERS + + # Walks `value` into codec-native Ruby. + # + # @param value [Object] anything: a model, a Tristate, a Hash, an Array, a scalar + # @param encoders [Hash{Class => #call}] the one hook, empty by default + # @return [Hash, Array, String, Integer, Float, true, false, nil] + # @raise [Dexpace::Serde::SerializationError] on a value no rule accepts, naming its class + # @raise [Dexpace::InvalidArgumentError] on an `encoders:` that is not a Hash of Class to + # callable + def of(value, encoders: NO_ENCODERS) + table = encoders!(encoders) + walked = walk(value, table) + walked.equal?(OMIT) ? nil : walked + end + + private + + def encoders!(encoders) + unless encoders.is_a?(::Hash) && encoders.all? do |k, v| + k.is_a?(::Module) && v.respond_to?(:call) + end + raise InvalidArgumentError, + "encoders must be a Hash of Class to #call, got #{encoders.class}" + end + + encoders + end + + def walk(value, encoders) + return value if native_scalar?(value) || value.equal?(OMIT) + return Model.frozen_string(value) if value.is_a?(::String) + return walk(value.public_send(DUMP_METHOD), encoders) if value.respond_to?(DUMP_METHOD) + return walk_hash(value, encoders) if value.is_a?(::Hash) + return walk_array(value, encoders) if value.is_a?(::Array) + + walk_encoded(value, encoders) + end + + def native_scalar?(value) + value.nil? || value.equal?(true) || value.equal?(false) || + value.is_a?(::Integer) || value.is_a?(::Float) + end + + def walk_hash(hash, encoders) + out = {} #: Hash[String, untyped] + hash.each_with_object(out) do |(key, raw), acc| + walked = walk(raw, encoders) + acc[key!(key)] = walked unless walked.equal?(OMIT) + end + end + + def walk_array(array, encoders) + array.map do |raw| + walked = walk(raw, encoders) + walked.equal?(OMIT) ? nil : walked + end + end + + def walk_encoded(value, encoders) + encoder = encoders[value.class] || encoders.find { |klass, _| value.is_a?(klass) }&.last + if encoder.nil? + raise SerializationError, + "#{value.class} is not a codec-native value: it answers no ##{DUMP_METHOD} and " \ + "no encoder is configured for it" + end + + walk(encoder.call(value), encoders) + end + + def key!(key) + case key + when ::String then Model.frozen_string(key) + when ::Symbol then key.name + else raise SerializationError, "a Hash key must be a String or a Symbol, got #{key.class}" + end + end + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/serde/nullable.rb b/gems/dexpace-core/lib/dexpace/serde/nullable.rb new file mode 100644 index 0000000..0cb0837 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/serde/nullable.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../model" +require_relative "scalars" +require_relative "decode_context" + +module Dexpace + module Serde + # The nullable combinator (SERDE-6): a witness that accepts a wire null where its element + # witness would not -- `Nullable.of(Pet)` decodes `null` to nil and anything else through `Pet`. + # + # It is the witness-aware half of SERDE-13's repair. `#load` screens nothing for nil itself, + # because THIS witness and `Tristate.of` legitimately want a top-level null (SERDE-20), and + # neither ever calls `ctx.object!` on nil -- so a null into a non-null target still fails + # through the element witness, naming the target, with no exemption anywhere. Resolves its + # element at construction (SERDE-8); a frozen Data in the phase-1 shape. + class Nullable < Data.define(:element) + include Model + + private_class_method :new, :[] + + # The ergonomic constructor design §7.3 names. + # + # @param element [Module, Object] the element witness, or a scalar class + # @return [Nullable] + # @raise [Dexpace::InvalidArgumentError] when `element` is not a witness (SERDE-8) + def self.of(element) = build(element: element) + + # The validating factory every construction path goes through. + # + # @param element [Module, Object] the element witness + # @return [Nullable] + # @raise [Dexpace::InvalidArgumentError] when `element` is not a witness (SERDE-8) + def self.build(element:) + new(element: element) + end + + def initialize(element:) + super(element: Scalars.resolve(element)) + end + + # The witness protocol: nil for a wire null, the element's decode otherwise. + # + # @param parsed [Object] + # @param ctx [Dexpace::Serde::DecodeContext] + # @return [Object, nil] + # @raise [Dexpace::Serde::DeserializationError] from the element witness + def dexpace_load(parsed, ctx) + return nil if parsed.nil? + + element.dexpace_load(parsed, ctx) + end + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/serde/scalars.rb b/gems/dexpace-core/lib/dexpace/serde/scalars.rb new file mode 100644 index 0000000..4ee3cf0 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/serde/scalars.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "witness" +require_relative "decode_context" + +module Dexpace + module Serde + # The scalar witnesses behind the ergonomic spellings design §7.3 uses verbatim -- + # `List.of(String)`, `Map.of(String, Pet)`, `Tristate.of(Float)` -- so a combinator's argument + # is uniformly "a witness" while a bare class object still works where a scalar is meant. A + # private_constant with a sig/ mirror, consulted by every combinator's constructor; not public + # API. The one public name it needs, BOOLEAN, is defined below it on Serde itself. + module Scalars + extend self + + # One scalar witness: a name for the diagnostics and the DecodeContext check it delegates to. + # Frozen singletons, so two `List.of(String)` share one element and compare equal. + class Scalar + # @param name [String] the textual form + # @param check [Proc] `(parsed, ctx) -> value`, one of DecodeContext's `!` methods + def initialize(name, check) + @name = name + @check = check + freeze + end + + # The witness protocol: delegates the shape check to the context (SERDE-21, SERDE-22). + # + # @param parsed [Object] + # @param ctx [Dexpace::Serde::DecodeContext] + # @return [Object] the checked value + def dexpace_load(parsed, ctx) = @check.call(parsed, ctx) + + # @return [String] the scalar's name + def to_s = @name + alias inspect to_s + end + + # The String witness: DecodeContext#string!. + STRING = Scalar.new("String", ->(parsed, ctx) { ctx.string!(parsed) }) + # The Integer witness: DecodeContext#integer!. + INTEGER = Scalar.new("Integer", ->(parsed, ctx) { ctx.integer!(parsed) }) + # The Float witness: DecodeContext#float!, with SERDE-22's widening. + FLOAT = Scalar.new("Float", ->(parsed, ctx) { ctx.float!(parsed) }) + # The boolean witness, published on Serde as BOOLEAN: DecodeContext#boolean!. + BOOLEAN = Scalar.new("Boolean", ->(parsed, ctx) { ctx.boolean!(parsed) }) + + # The class-to-witness table. ::Time is deliberately absent: the ISO-8601 wiring is the + # adapter's per design §3.4, and a core mapping would make it every codec's default. Booleans + # are not keyed on TrueClass/FalseClass either -- that is what BOOLEAN is for. + TABLE = { ::String => STRING, ::Integer => INTEGER, ::Float => FLOAT }.freeze + + # The one place the ergonomic spelling and the protocol meet: a class in the table resolves + # to its scalar witness, and anything else must be a witness in its own right (SERDE-8's + # fail-fast, at construction). + # + # @param witness [Module, Object] a table class or a witness + # @return [Object] a witness + # @raise [Dexpace::InvalidArgumentError] when it is neither + def resolve(witness) + TABLE.fetch(witness) { Dexpace::Serde.witness!(witness) } + end + end + private_constant :Scalars + + # The boolean scalar witness -- `List.of(Dexpace::Serde::BOOLEAN)` -- a NAMED witness rather + # than two class keys, because Ruby has no Boolean class to key on and `List.of(TrueClass)` + # would read as a list of `true`s. Accepts exactly `true` and `false` (SERDE-21). + BOOLEAN = Scalars::BOOLEAN + end +end diff --git a/gems/dexpace-core/lib/dexpace/serde/status_aware_handler.rb b/gems/dexpace-core/lib/dexpace/serde/status_aware_handler.rb new file mode 100644 index 0000000..cf33375 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/serde/status_aware_handler.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../model" +require_relative "../error/invalid_argument_error" +require_relative "../error/protocol_error" +require_relative "../http/response" +require_relative "../recovery" +require_relative "../registry" +require_relative "deserialization_error" +require_relative "decode_context" +require_relative "decoding_handler" + +module Dexpace + module Serde + # SERDE-28's status-aware response handler: a Dexpace::_ResponseHandler supplied into phase + # 3b's TypedResponse, dispatching on the status in three branches -- + # + # 2xx -> the DecodingHandler built from the same serde and witness + # 4xx / 5xx -> raise factory.call(Recovery.buffer_error_body(response)) + # anything else (1xx, 3xx, 304) -> close the response; raise DeserializationError, " + # ..." + # + # The 2xx branch DELEGATES rather than re-implementing, so SERDE-27's five clauses have exactly + # one implementation and "decode the body only on a 2xx status" is a branch, not a second + # decoder. The 4xx/5xx branch uses phase 4b's objects and adds none: Recovery.buffer_error_body + # is the ONE buffering call site (RECOV-16, BODY-30, HTTP-52 -- it already returns the response + # unchanged when the body is nil, so no nil check here), and its Body.buffer_bounded closes the + # live body in its own `ensure`, so this branch adds NO close of its own -- a second one would + # be a double close of an object 4b already released, which is why the suite counts raw closes. + # `factory:` defaults to core's ProtocolError.for as one frozen lambda, the exact shape + # Recovery::ErrorMappingStep uses, so a generated SDK substitutes its typed errors here with the + # keyword it already knows from the recovery chain and SERDE-28's "the MAPPED HTTP-error + # exception" is RECOV-15's object rather than a second one; `.for` and not `.for_or_nil`, + # because the branch has already established `status.error?`. The error is raised `cause: nil`: + # the factory CONSTRUCTED it, and a bare `raise` inside a caller's rescue would chain the + # caller's in-flight exception onto it (`pipeline/7ce4431d`). + # + # The third branch is where SERDE-28 is easiest to get half-right: the message "leads with the + # status code and preserves conditional/redirect context (ETag / Location)", so it copies the + # RAW `ETag` and `Location` header values and parses neither -- running a malformed server ETag + # through HTTP-48's validating helper inside an error path would turn a diagnostic into a second + # failure. The chapter's conformance clause also names "a non-canonical 599", which is a 5xx + # and therefore the SECOND branch; it is called out here because a reader skimming + # "non-canonical" will file it under the third. + # + # A frozen Data in the phase-1 shape. Its members are the three build keywords, so `#with` + # re-derives the whole thing through `.build`; the DecodingHandler for the 2xx branch is built + # once in `#initialize` -- which is also where the serde and the witness are validated. + class StatusAwareHandler < Data.define(:serde, :witness, :factory) + include Model + + # Core's factory as a callable: one frozen object, the same proc type a caller's factory + # has (ErrorMappingStep's shape). Private, because P7-2 fixes the public constants. + DEFAULT_FACTORY = ->(response) { ProtocolError.for(response) }.freeze + private_constant :DEFAULT_FACTORY + + private_class_method :new, :[] + + # The validating factory every construction path goes through. + # + # @param serde [Dexpace::_Codec] the codec, `#load(source, witness)` at least + # @param witness [Object] the SUCCESS type's witness: a class answering .dexpace_load, or a + # combinator; the error payload never reaches it + # @param factory [#call] `#call(buffered_response) -> Exception`, ProtocolError.for by default + # @return [StatusAwareHandler] frozen + # @raise [Dexpace::InvalidArgumentError] on a nil serde, a non-witness, or a nil or + # non-callable factory + def self.build(serde:, witness:, factory: DEFAULT_FACTORY) + new(serde: serde, witness: witness, factory: factory) + end + + def initialize(serde:, witness:, factory:) + Model.required!("factory", factory) + unless Registry.callable?(factory, arity: 1) + raise InvalidArgumentError, "factory must respond to #call(response)" + end + + # Validates the serde and the witness (SERDE-8, at construction) and is the one decoder. + @decoding = DecodingHandler.build(serde: serde, witness: witness) + super(serde: @decoding.serde, witness: @decoding.witness, factory: factory) + end + + # The _ResponseHandler protocol, dispatched on the status (SERDE-28). + # + # @param response [Dexpace::Response] + # @return [Object] the witness's decode, for a 2xx + # @raise [Exception] the factory's error, for a 4xx or 5xx, carrying the buffered response + # @raise [Dexpace::Serde::DeserializationError] for a 1xx or a 3xx, leading with the code, or + # from the decoder on a 2xx with a missing, empty or malformed body + # @raise [Dexpace::InvalidArgumentError] when `response` is not a Dexpace::Response, or the + # factory returned something that is not an Exception + def call(response) + unless response.is_a?(Response) + raise InvalidArgumentError, "response must be a Dexpace::Response, got #{response.class}" + end + return @decoding.call(response) if response.success? + return raise_mapped(response) if response.error? + + raise_unhandled(response) + end + + private + + # 4xx/5xx: buffer (which closes the live response), map, raise. No second close. + def raise_mapped(response) + error = factory.call(Recovery.buffer_error_body(response)) + unless error.is_a?(::Exception) + raise InvalidArgumentError, + "factory must return an Exception for status #{response.status.code}, " \ + "got #{error.class}" + end + + raise error, cause: nil + end + + # 1xx or 3xx: close, then raise with the code first and the raw conditional/redirect headers. + def raise_unhandled(response) + message = unhandled_message(response) + response.close + raise DeserializationError, message + end + + def unhandled_message(response) + status = response.status + headers = response.headers + context = %w[etag location].filter_map do |name| + values = headers[name] + "#{name}: #{values.join(", ")}" unless values.nil? || values.empty? + end + target = DecodeContext.root(target: witness).target + lead = "#{status.code} #{status.canonical_name}".rstrip + ["#{lead}: not decoded into #{target}, only a 2xx body is (SERDE-28)", *context].join("; ") + end + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/serde/tristate.rb b/gems/dexpace-core/lib/dexpace/serde/tristate.rb new file mode 100644 index 0000000..dd6da2a --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/serde/tristate.rb @@ -0,0 +1,226 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../model" +require_relative "../error/invalid_argument_error" +require_relative "native" +require_relative "scalars" +require_relative "decode_context" + +module Dexpace + module Serde + # The three-state PATCH type (SERDE-14–SERDE-20, SERDE-30): Absent (the key is missing), Null + # (the key is present with an explicit null) and Present (the key carries a value). A module + # included by all three values -- exactly Outcome's shape (4b) and Context's (4a) -- so + # `v.is_a?(Dexpace::Serde::Tristate)` is one type test and the RBS union has a name. + # + # SERDE-14's illegal fourth state, Present-of-null, is unrepresentable on BOTH paths: `Present` + # validates in `#initialize`, so `.build`, the private `.new` and `.[]`, and the + # send-past-private hole P8 records all refuse nil; and `Dexpace::Model#with` routes a + # derivation through `.build` on every supported Ruby (`data-modeling/83610619`: Data#with skips + # an `initialize` override on 3.2), so `Tristate.present(1).with(value: nil)` raises too. The + # covariance clause is satisfied by the language (design §11.15) and has no code. + # + # `.of(element)` and `.from_nullable(value)` are two different things with two different names, + # deliberately: the first is the decode-side combinator design §7.3 names, the second is + # SERDE-18's "nullable-to-(present|null) mapper that can never yield Absent". One `.of` doing + # both would be the API-design mistake SERDE-18's own conformance clause exists to catch. + # + # `#dexpace_dump` is where the type meets Native's walk: OMIT for Absent (the walk drops the + # key, SERDE-15), nil for Null, the inner value for Present (re-walked, so a nested model or a + # nested Tristate is handled by the same recursion). The two sentinels override `#to_s` and + # `#inspect` (SERDE-30, taken) because Ruby's default `#inspect` renders an object id, which + # would make a log line or a test failure differ between runs; the frozen singletons are + # Ractor-shareable as a free side effect that no claim rests on. + module Tristate + # @return [Boolean] whether this is the Absent value + def absent? = false + + # @return [Boolean] whether this is the Null value + def null? = false + + # @return [Boolean] whether this is a Present value + def present? = false + + # SERDE-18's value-or-null accessor: the inner value for Present, nil otherwise. + # + # @return [Object, nil] + def value_or_nil = nil + + # SERDE-18's three-way fold. + # + # @param on_absent [#call] called with no argument for Absent + # @param on_null [#call] called with no argument for Null + # @param on_present [#call] called with the inner value for Present + # @return [Object] whatever the matching callable returned + def fold(on_absent:, on_null:, on_present:) + if present? + on_present.call(value_or_nil) + elsif null? + on_null.call + else + on_absent.call + end + end + + # The Absent sentinel's class: private, so a caller reaches the value through ABSENT alone. + class Absent + include Tristate + + # @return [true] + def absent? = true + + # SERDE-15: an Absent field's key is omitted; the walk drops OMIT. + # + # @return [Dexpace::Serde::OMIT] + def dexpace_dump = OMIT + + # @return [String] "Absent" (SERDE-30) + def to_s = "Absent" + alias inspect to_s + end + + # The Null sentinel's class: private, so a caller reaches the value through NULL alone. + class Null + include Tristate + + # @return [true] + def null? = true + + # SERDE-15: a Null field emits its key with a wire null. + # + # @return [nil] + def dexpace_dump = nil + + # @return [String] "Null" (SERDE-30) + def to_s = "Null" + alias inspect to_s + end + private_constant :Absent, :Null + + # The Absent value: the key is missing. + ABSENT = Absent.new.freeze + + # The Null value: the key is present with an explicit null. + NULL = Null.new.freeze + + # A Present value, bounded to non-null (SERDE-14). A frozen Data in the phase-1 shape whose + # equality is the inner value's. + class Present < Data.define(:value) + include Model + include Tristate + + private_class_method :new, :[] + + # The validating factory every construction path goes through (`#with` included). + # + # @param value [Object] non-nil + # @return [Present] + # @raise [Dexpace::InvalidArgumentError] "value is required", on nil (SERDE-14, SEAM-29) + def self.build(value:) + new(value: value) + end + + def initialize(value:) + Model.required!("value", value) + super + end + + # @return [true] + def present? = true + + # @return [Object] the inner value + def value_or_nil = value + + # SERDE-15: a Present field emits its key with the encoded inner value; Native re-walks it. + # + # @return [Object] the inner value + def dexpace_dump = value + end + + # The decode-side combinator `.of` returns: a witness for a tri-state field. Private, because + # `.of` is the whole of its public surface; a frozen Data by value from its element witness. + class Combinator < Data.define(:element) + include Model + + private_class_method :new, :[] + + # @param element [Module, Object] the element witness, or a scalar class the table knows + # @return [Combinator] + def self.build(element:) + new(element: element) + end + + def initialize(element:) + super(element: Scalars.resolve(element)) + end + + # SERDE-20's top-level case: the protocol entry point sees only the value, so a null is + # Null and anything else is Present of the element's decode. + # + # @param parsed [Object] + # @param ctx [Dexpace::Serde::DecodeContext] + # @return [Tristate] + def dexpace_load(parsed, ctx) + return NULL if parsed.nil? + + Present.build(value: element.dexpace_load(parsed, ctx)) + end + + # SERDE-16/SERDE-17's in-object case, three lines because of verified fact 6: the enclosing + # Hash answers `key?` directly, so a missing key is Absent, a present null is Null and a + # present value is Present of the element's decode at the key's own path. No field-default + # machinery, none emulated. + # + # @param hash [Hash] the enclosing parsed object + # @param key [String] the field's key + # @param ctx [Dexpace::Serde::DecodeContext] the enclosing object's context + # @return [Tristate] + def dexpace_load_field(hash, key, ctx) + entries = ctx.object!(hash) + return ABSENT unless entries.key?(key) + + value = entries[key] + return NULL if value.nil? + + Present.build(value: element.dexpace_load(value, ctx.at(key))) + end + end + private_constant :Combinator + + class << self + # SERDE-18: the Absent factory. + # + # @return [Tristate] ABSENT + def absent = ABSENT + + # SERDE-18: the explicit-null factory. + # + # @return [Tristate] NULL + def null = NULL + + # SERDE-18: the Present factory, refusing nil. + # + # @param value [Object] non-nil + # @return [Tristate::Present] + # @raise [Dexpace::InvalidArgumentError] on nil (SERDE-14) + def present(value) = Present.build(value: value) + + # SERDE-18's nullable mapper: Present for a non-nil value, Null for nil, and NEVER Absent. + # + # @param value [Object, nil] + # @return [Tristate] + def from_nullable(value) = value.nil? ? NULL : Present.build(value: value) + + # The decode-side combinator (design §7.3): a witness for a tri-state field of `element`, + # with `#dexpace_load_field(hash, key, ctx)` for the in-object case and the protocol's + # `#dexpace_load(parsed, ctx)` for the top-level one. + # + # @param element [Module, Object] the element witness, or a scalar class + # @return [Object] a witness + # @raise [Dexpace::InvalidArgumentError] when `element` is not a witness (SERDE-8) + def of(element) = Combinator.build(element: element) + end + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/serde/witness.rb b/gems/dexpace-core/lib/dexpace/serde/witness.rb new file mode 100644 index 0000000..b0d8091 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/serde/witness.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../serde" +require_relative "../error/invalid_argument_error" + +module Dexpace + # Reopened for the witness protocol, the way closeable.rb defines Dexpace.close_quietly on the + # root namespace: the seam module is phase 2's serde.rb and this file adds the four names the + # protocol needs, in the file that mirrors them (design §7.3, §10.14). + module Serde + # The one name a witness answers: `.dexpace_load(parsed, ctx)`, a class method on a model + # class or an instance method on a combinator. Public, and frozen as every Symbol is, so a code + # generator emitting witnesses reads the name from here rather than hard-coding a string this + # repository could rename; `Native`'s walk and `.witness?` both read it, so `:dexpace_load` is + # written exactly once. + WITNESS_METHOD = :dexpace_load + + # The one name an encodable value answers: `#dexpace_dump`, a zero-argument instance method + # returning the value's codec-native form (a Hash, an Array, a scalar, or OMIT). The encode side + # takes no context, because Ruby erases nothing -- every object carries its class -- so there + # is no DumpContext and this is the whole protocol. + DUMP_METHOD = :dexpace_dump + + class << self + # Whether `object` is a witness: anything responding to .dexpace_load(parsed, ctx). A + # `respond_to?` test and never a nominal one, for the reason every seam here gives -- a model + # class implements the protocol as a class method and a combinator instance as an instance + # method, and one predicate must cover both (verified fact 15). The set of witnesses is + # therefore OPEN: a caller writes a Set witness or a discriminated-union witness with no + # registration, no subclassing and no core change, in two lines: + # + # class SetOf + # def initialize(element) = @element = Dexpace::Serde.witness!(element) + # def dexpace_load(parsed, ctx) = ctx.array!(parsed).each_with_index.to_set { |v, i| ... } + # end + # + # A `#call`-shaped object is deliberately NOT a witness. SERDE-5 requires an explicit runtime + # type witness and SERDE-8 requires construction to fail fast without one; a `#call` fallback + # would make every lambda a witness and both MUSTs unenforceable. + # + # @param object [Object] + # @return [Boolean] + def witness?(object) = object.respond_to?(WITNESS_METHOD) + + # SERDE-8's raising half: the object back when it is a witness, and an actionable + # InvalidArgumentError naming the missing method and the object's class otherwise. Every + # combinator's `.of` and both response handlers' `.build` run their arguments through this, + # which is what puts the failure at witness CONSTRUCTION -- earlier than the reference's + # binder-resolution failure -- and what makes SERDE-8's "unresolved type variable" state + # unreachable: a combinator cannot exist without a concrete element witness. + # + # @param object [Object] + # @return [Object] `object` + # @raise [Dexpace::InvalidArgumentError] when `object` does not answer .dexpace_load + def witness!(object) + return object if witness?(object) + + raise InvalidArgumentError, + "a witness must respond to .#{WITNESS_METHOD}(parsed, ctx); #{object.class} does not" + end + end + end +end diff --git a/gems/dexpace-core/sig/dexpace/http/body.rbs b/gems/dexpace-core/sig/dexpace/http/body.rbs index db508b9..7073456 100644 --- a/gems/dexpace-core/sig/dexpace/http/body.rbs +++ b/gems/dexpace-core/sig/dexpace/http/body.rbs @@ -58,6 +58,7 @@ module Dexpace ?subtype: String) -> Dexpace::MultipartBody def self.buffer: (Dexpace::IO::Buffer buffer, ?media_type: Dexpace::MediaType?) -> Dexpace::BufferBody + def self.serialized: (untyped value, serde: untyped) -> Dexpace::BytesBody def self.buffer_bounded: (Dexpace::Body body, ?cap: Integer | Float) -> Dexpace::BufferBody private def self.clamp_cap: (untyped cap) -> Integer diff --git a/gems/dexpace-core/sig/dexpace/serde.rbs b/gems/dexpace-core/sig/dexpace/serde.rbs index 4fbc3a3..0b97b93 100644 --- a/gems/dexpace-core/sig/dexpace/serde.rbs +++ b/gems/dexpace-core/sig/dexpace/serde.rbs @@ -1,13 +1,17 @@ module Dexpace - # The static half of the codec seam: the six methods, typed. `value` and the witness are - # untyped because the witness protocol is phase 7's (§7.3) and a codec's value space is its own. + # The static half of the codec seam: the six methods, typed. `value` is untyped because a + # codec's value space is its own. Phase 7a settled the three clauses phase 2 left loose: + # #media_type answers a Dexpace::MediaType or a String (Body.serialized coerces the String; + # SERDE-2), #load takes a Dexpace::Serde::_Witness (design §7.3's protocol), and #dump_to answers + # the byte count written, as the other three encode profiles do. `source` stays untyped because + # SERDE-3's subject is "a caller-supplied stream" and phase 2's own test passes a StringIO. interface _Codec - def media_type: () -> String + def media_type: () -> (Dexpace::MediaType | String) def dump_string: (untyped value) -> String def dump_bytes: (untyped value) -> String - def dump_to: (untyped value, untyped sink) -> untyped + def dump_to: (untyped value, untyped sink) -> Integer def dump_into: (untyped value, String buffer, offset: Integer) -> Integer - def load: (untyped source, untyped witness) -> untyped + def load: (untyped source, Dexpace::Serde::_Witness witness) -> untyped end module Serde diff --git a/gems/dexpace-core/sig/dexpace/serde/decode_context.rbs b/gems/dexpace-core/sig/dexpace/serde/decode_context.rbs new file mode 100644 index 0000000..b5199aa --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/serde/decode_context.rbs @@ -0,0 +1,38 @@ +module Dexpace + module Serde + # The `ctx` a witness receives: the path from the document root, the decode's target type + # name, and the one raise site for a shape failure (SERDE-13, SERDE-21, SERDE-22). A frozen + # Data in the phase-1 shape; .new and .[] are private and .build is the validating factory. + class DecodeContext < Data + include Model + + attr_reader path: Array[String | Integer] + attr_reader target: String? + + # Two private_constants with no visibility in RBS; declared so Steep can type .root. + EMPTY_PATH: Array[String | Integer] + ROOT: DecodeContext + + private def self.new: (path: Array[String | Integer], target: String?) -> instance + def initialize: (path: Array[String | Integer], target: String?) -> void + + def self.build: (path: Array[String | Integer], target: String?) -> DecodeContext + def self.root: (?target: untyped) -> DecodeContext + + def at: (String | Integer segment) -> DecodeContext + def pointer: () -> String + def object!: (untyped value, ?key: (String | Integer)?) -> Hash[untyped, untyped] + def array!: (untyped value, ?key: (String | Integer)?) -> Array[untyped] + def string!: (untyped value, ?key: (String | Integer)?) -> String + def integer!: (untyped value, ?key: (String | Integer)?) -> Integer + def float!: (untyped value, ?key: (String | Integer)?) -> Float + def boolean!: (untyped value, ?key: (String | Integer)?) -> bool + def present!: (untyped value, String target, ?key: (String | Integer)?) -> untyped + def error!: (expected: String, actual: untyped, ?key: (String | Integer)?) -> bot + + private + + def segment?: (untyped segment) -> bool + end + end +end diff --git a/gems/dexpace-core/sig/dexpace/serde/decoding_handler.rbs b/gems/dexpace-core/sig/dexpace/serde/decoding_handler.rbs new file mode 100644 index 0000000..88e0042 --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/serde/decoding_handler.rbs @@ -0,0 +1,23 @@ +module Dexpace + module Serde + # SERDE-27's response-decoding handler, a Dexpace::_ResponseHandler supplied into + # TypedResponse. A frozen Data; .new and .[] are private and .build is the validating factory. + class DecodingHandler < Data + include Model + + attr_reader serde: untyped + attr_reader witness: untyped + + private def self.new: (serde: untyped, witness: untyped) -> instance + def initialize: (serde: untyped, witness: untyped) -> void + + def self.build: (serde: untyped, witness: untyped) -> DecodingHandler + def call: (Dexpace::Response response) -> untyped + + private + + def decode: (Dexpace::Response response) -> untyped + def missing_body: () -> Dexpace::Serde::DeserializationError + end + end +end diff --git a/gems/dexpace-core/sig/dexpace/serde/instant.rbs b/gems/dexpace-core/sig/dexpace/serde/instant.rbs new file mode 100644 index 0000000..0178937 --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/serde/instant.rbs @@ -0,0 +1,14 @@ +module Dexpace + module Serde + # The ISO-8601 instant witness (SERDE-24): the decode half parses, the encode half renders at + # microsecond precision (P7-8 states the round-trip domain). ::Time is the one foreign constant + # in core's serde signatures and is on NFR-11's stdlib allowlist. + module Instant + # A private_constant with no visibility in RBS; declared so Steep can type the two raises. + EXPECTED: String + + def self?.dexpace_load: (untyped parsed, Dexpace::Serde::DecodeContext ctx) -> Time + def self?.dexpace_dump: (untyped time) -> String + end + end +end diff --git a/gems/dexpace-core/sig/dexpace/serde/list.rbs b/gems/dexpace-core/sig/dexpace/serde/list.rbs new file mode 100644 index 0000000..9e7eda0 --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/serde/list.rbs @@ -0,0 +1,18 @@ +module Dexpace + module Serde + # The Array combinator (SERDE-6): a witness for Array[element], built by value. A frozen Data; + # .new and .[] are private and .build is the validating factory .of routes through. + class List < Data + include Model + + attr_reader element: untyped + + private def self.new: (element: untyped) -> instance + def initialize: (element: untyped) -> void + + def self.of: (untyped element) -> List + def self.build: (element: untyped) -> List + def dexpace_load: (untyped parsed, Dexpace::Serde::DecodeContext ctx) -> Array[untyped] + end + end +end diff --git a/gems/dexpace-core/sig/dexpace/serde/map.rbs b/gems/dexpace-core/sig/dexpace/serde/map.rbs new file mode 100644 index 0000000..44246f5 --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/serde/map.rbs @@ -0,0 +1,19 @@ +module Dexpace + module Serde + # The Hash combinator (SERDE-6): a witness for Hash[key, value], built by value from a key + # witness and a value witness. A frozen Data; .new and .[] are private. + class Map < Data + include Model + + attr_reader key: untyped + attr_reader value: untyped + + private def self.new: (key: untyped, value: untyped) -> instance + def initialize: (key: untyped, value: untyped) -> void + + def self.of: (untyped key, untyped value) -> Map + def self.build: (key: untyped, value: untyped) -> Map + def dexpace_load: (untyped parsed, Dexpace::Serde::DecodeContext ctx) -> Hash[untyped, untyped] + end + end +end diff --git a/gems/dexpace-core/sig/dexpace/serde/native.rbs b/gems/dexpace-core/sig/dexpace/serde/native.rbs new file mode 100644 index 0000000..870fd1a --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/serde/native.rbs @@ -0,0 +1,31 @@ +module Dexpace + module Serde + # A private_constant with no visibility in RBS; declared because the strict `core` Steep + # target types OMIT's construction. The privacy lives in lib/dexpace/serde/native.rb. + class Omit + def to_s: () -> String + def inspect: () -> String + end + + OMIT: Omit + + # The encode walk (P7-9): SERDE-15's key omission, SERDE-20's three degradations and SERDE-9's + # loud failure on an unencodable value, in one place a codec adapter calls. + module Native + # A private_constant with no visibility in RBS; declared so Steep can type .of's default. + NO_ENCODERS: Hash[Module, untyped] + + def self?.of: (untyped value, ?encoders: Hash[Module, untyped]) -> untyped + + private + + def encoders!: (untyped encoders) -> Hash[Module, untyped] + def walk: (untyped value, Hash[Module, untyped] encoders) -> untyped + def native_scalar?: (untyped value) -> bool + def walk_hash: (Hash[untyped, untyped] hash, Hash[Module, untyped] encoders) -> Hash[String, untyped] + def walk_array: (Array[untyped] array, Hash[Module, untyped] encoders) -> Array[untyped] + def walk_encoded: (untyped value, Hash[Module, untyped] encoders) -> untyped + def key!: (untyped key) -> String + end + end +end diff --git a/gems/dexpace-core/sig/dexpace/serde/nullable.rbs b/gems/dexpace-core/sig/dexpace/serde/nullable.rbs new file mode 100644 index 0000000..9045a88 --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/serde/nullable.rbs @@ -0,0 +1,18 @@ +module Dexpace + module Serde + # The nullable combinator (SERDE-6): a witness accepting a wire null where its element would + # not. A frozen Data; .new and .[] are private. + class Nullable < Data + include Model + + attr_reader element: untyped + + private def self.new: (element: untyped) -> instance + def initialize: (element: untyped) -> void + + def self.of: (untyped element) -> Nullable + def self.build: (element: untyped) -> Nullable + def dexpace_load: (untyped parsed, Dexpace::Serde::DecodeContext ctx) -> untyped + end + end +end diff --git a/gems/dexpace-core/sig/dexpace/serde/scalars.rbs b/gems/dexpace-core/sig/dexpace/serde/scalars.rbs new file mode 100644 index 0000000..3fe19aa --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/serde/scalars.rbs @@ -0,0 +1,30 @@ +# Dexpace::Serde::Scalars is a private_constant and not public API: this declaration exists +# because the strict `core` Steep target checks every file under lib/ and needs the module, its +# table and its witness class declared to type the combinators' constructors. RBS has no +# visibility for a constant, so the privacy lives in lib/dexpace/serde/scalars.rb alone. +module Dexpace + module Serde + module Scalars + class Scalar + @name: String + @check: ^(untyped, Dexpace::Serde::DecodeContext) -> untyped + + def initialize: (String name, ^(untyped, Dexpace::Serde::DecodeContext) -> untyped check) -> void + def dexpace_load: (untyped parsed, Dexpace::Serde::DecodeContext ctx) -> untyped + def to_s: () -> String + def inspect: () -> String + end + + STRING: Scalar + INTEGER: Scalar + FLOAT: Scalar + BOOLEAN: Scalar + TABLE: Hash[Module, Scalar] + + def self?.resolve: (untyped witness) -> untyped + end + + # The boolean scalar witness: a named witness where the other three scalars are class objects. + BOOLEAN: _Witness + end +end diff --git a/gems/dexpace-core/sig/dexpace/serde/status_aware_handler.rbs b/gems/dexpace-core/sig/dexpace/serde/status_aware_handler.rbs new file mode 100644 index 0000000..dc49df1 --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/serde/status_aware_handler.rbs @@ -0,0 +1,31 @@ +module Dexpace + module Serde + # SERDE-28's status-aware response handler, a Dexpace::_ResponseHandler supplied into + # TypedResponse: 2xx decodes, 4xx/5xx raises the mapped error over the buffered body, anything + # else closes and raises leading with the code. A frozen Data; .new and .[] are private. + class StatusAwareHandler < Data + include Model + + # A private_constant with no visibility in RBS; declared so Steep can type .build's default. + DEFAULT_FACTORY: ^(Dexpace::Response) -> Exception + + @decoding: DecodingHandler + + attr_reader serde: untyped + attr_reader witness: untyped + attr_reader factory: untyped + + private def self.new: (serde: untyped, witness: untyped, factory: untyped) -> instance + def initialize: (serde: untyped, witness: untyped, factory: untyped) -> void + + def self.build: (serde: untyped, witness: untyped, ?factory: untyped) -> StatusAwareHandler + def call: (Dexpace::Response response) -> untyped + + private + + def raise_mapped: (Dexpace::Response response) -> bot + def raise_unhandled: (Dexpace::Response response) -> bot + def unhandled_message: (Dexpace::Response response) -> String + end + end +end diff --git a/gems/dexpace-core/sig/dexpace/serde/tristate.rbs b/gems/dexpace-core/sig/dexpace/serde/tristate.rbs new file mode 100644 index 0000000..0fb11fa --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/serde/tristate.rbs @@ -0,0 +1,75 @@ +module Dexpace + module Serde + # The three-state PATCH type (SERDE-14–SERDE-20, SERDE-30): a module included by ABSENT, NULL + # and Present, so one type test covers the three values. + module Tristate + def absent?: () -> bool + def null?: () -> bool + def present?: () -> bool + def value_or_nil: () -> untyped + def fold: (on_absent: ^() -> untyped, on_null: ^() -> untyped, on_present: ^(untyped) -> untyped) -> untyped + + # Two private_constants with no visibility in RBS; declared because the strict `core` Steep + # target types the two sentinels' construction. The privacy lives in + # lib/dexpace/serde/tristate.rb. + class Absent + include Tristate + + def absent?: () -> bool + def dexpace_dump: () -> Omit + def to_s: () -> String + def inspect: () -> String + end + + class Null + include Tristate + + def null?: () -> bool + def dexpace_dump: () -> nil + def to_s: () -> String + def inspect: () -> String + end + + ABSENT: Tristate + NULL: Tristate + + # A Present value, bounded to non-null (SERDE-14). A frozen Data; .new and .[] are private + # and .build is the validating factory. + class Present < Data + include Model + include Tristate + + attr_reader value: untyped + + private def self.new: (value: untyped) -> instance + def initialize: (value: untyped) -> void + + def self.build: (value: untyped) -> Present + def present?: () -> bool + def value_or_nil: () -> untyped + def dexpace_dump: () -> untyped + end + + # A private_constant with no visibility in RBS; declared because the strict `core` Steep + # target types .of. The privacy lives in lib/dexpace/serde/tristate.rb. + class Combinator < Data + include Model + + attr_reader element: untyped + + private def self.new: (element: untyped) -> instance + def initialize: (element: untyped) -> void + + def self.build: (element: untyped) -> Combinator + def dexpace_load: (untyped parsed, Dexpace::Serde::DecodeContext ctx) -> Tristate + def dexpace_load_field: (untyped hash, String key, Dexpace::Serde::DecodeContext ctx) -> Tristate + end + + def self.absent: () -> Tristate + def self.null: () -> Tristate + def self.present: (untyped value) -> Present + def self.from_nullable: (untyped value) -> Tristate + def self.of: (untyped element) -> Combinator + end + end +end diff --git a/gems/dexpace-core/sig/dexpace/serde/witness.rbs b/gems/dexpace-core/sig/dexpace/serde/witness.rbs new file mode 100644 index 0000000..b1013e4 --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/serde/witness.rbs @@ -0,0 +1,18 @@ +module Dexpace + module Serde + # The witness protocol (design §7.3, §10.14): any object answering .dexpace_load(parsed, ctx). + # An interface and not a class, because a structural duck type is what it is -- a caller's + # own model class type-checks against List.of without inheriting anything. `parsed` and the + # return are untyped because a witness's target type is the caller's; RBS and Steep are a + # gate rather than a guarantee here. + interface _Witness + def dexpace_load: (untyped parsed, Dexpace::Serde::DecodeContext ctx) -> untyped + end + + WITNESS_METHOD: Symbol + DUMP_METHOD: Symbol + + def self.witness?: (untyped object) -> bool + def self.witness!: (untyped object) -> untyped + end +end diff --git a/gems/dexpace-core/test/dexpace/seam_surface_test.rb b/gems/dexpace-core/test/dexpace/seam_surface_test.rb index 521ea0f..d798281 100644 --- a/gems/dexpace-core/test/dexpace/seam_surface_test.rb +++ b/gems/dexpace-core/test/dexpace/seam_surface_test.rb @@ -13,16 +13,36 @@ class DexpaceSeamSurfaceTest < DexpaceTestCase # Every concrete implementation SEAM-2 could tempt an error message into naming. CONCRETE = %r{net_http|async_http|net/http|async-http|\bjson\b|\boj\b|httpx|excon|typhoeus}i + # "Starts empty on a bare require" is a property of a process that required `dexpace` ALONE, and + # `rake test:gems` is not one: it runs every gem's suite in one process, and an adapter registers + # itself against its seam the moment its entry file loads (design §3.6; phase 7a's + # dexpace-serde-json is the first). So the two properties below are asserted in a CHILD process -- + # the shape instrumentation/independence_test.rb uses -- that requires core and nothing else, and + # prints one line per seam. Converted by phase 7a as pins its registration invalidated. + GEM_ROOT = File.expand_path("../..", __dir__) + BARE_REQUIRE = <<~RUBY + require "dexpace" + [Dexpace::Transport, Dexpace::AsyncTransport, Dexpace::Serde].each do |seam| + keys = seam.registered_keys + message = + begin + seam.resolve + "RESOLVED" + rescue Dexpace::SeamError => error + error.message + end + puts [seam.name, keys.inspect, message].join("\t") + end + RUBY + test "every seam registry starts empty on a bare require" do - SEAMS.each { |seam| assert_empty(seam.registered_keys, "#{seam} is not empty") } + bare_require_report.each { |name, keys, _| assert_equal("[]", keys, "#{name} is not empty") } end test "no seam's zero-candidate error names a concrete gem" do - SEAMS.each do |seam| - error = assert_raises(Dexpace::SeamError) { seam.resolve } - - refute_match(CONCRETE, error.message, - "SEAM-2: #{seam} names a concrete implementation in its error path",) + bare_require_report.each do |name, _, message| + refute_equal("RESOLVED", message, "#{name} resolved something on a bare require") + refute_match(CONCRETE, message, "SEAM-2: #{name} names a concrete implementation") end end @@ -87,4 +107,17 @@ class DexpaceSeamSurfaceTest < DexpaceTestCase registries.each { |registry| assert_instance_of(Dexpace::Registry, registry) } assert_equal(["transport", "async transport", "codec"], registries.map(&:seam)) end + + private + + # One row per seam from the child: [name, registered keys' inspect, the resolve outcome]. + def bare_require_report + command = [::RbConfig.ruby, "-w", "-Ilib", "-e", BARE_REQUIRE] + out = IO.popen(command, err: %i[child out], chdir: GEM_ROOT, &:read) + rows = out.lines.map { |line| line.chomp.split("\t", 3) } + + assert_equal(SEAMS.map(&:name), rows.map(&:first), + "the child did not report every seam:\n#{out}",) + rows + end end diff --git a/gems/dexpace-core/test/dexpace/serde_test.rb b/gems/dexpace-core/test/dexpace/serde_test.rb index 31d215c..9b81f7f 100644 --- a/gems/dexpace-core/test/dexpace/serde_test.rb +++ b/gems/dexpace-core/test/dexpace/serde_test.rb @@ -38,14 +38,27 @@ class DexpaceSerdeTest < DexpaceTestCase assert_equal(%i[media_type], Dexpace::Serde.missing_methods(forgetful)) end - test "the registry starts empty and its error names this seam and no gem" do - assert_empty(Dexpace::Serde.registered_keys) - - error = assert_raises(Dexpace::SeamError) { Dexpace::Serde.resolve } + # A property of a process that required `dexpace` ALONE -- `rake test:gems` runs every gem's + # suite in one process and dexpace-serde-json registers itself under :json as its entry file + # loads (design §3.6), so this is asserted in a child process, the shape + # instrumentation/independence_test.rb uses. Converted by phase 7a as a pin its registration + # invalidated; the message assertions are unchanged. + BARE_REQUIRE = <<~RUBY + require "dexpace" + puts Dexpace::Serde.registered_keys.inspect + puts(Dexpace::Serde.resolve.then { "RESOLVED" }) rescue puts($!.message) + RUBY + GEM_ROOT = File.expand_path("../..", __dir__) - assert_match(/no codec provider is registered/, error.message) - assert_match(/Dexpace::Serde\.install/, error.message) - refute_match(/json|oj/i, error.message, "SEAM-2") + test "the registry starts empty and its error names this seam and no gem" do + command = [::RbConfig.ruby, "-w", "-Ilib", "-e", BARE_REQUIRE] + out = IO.popen(command, err: %i[child out], chdir: GEM_ROOT, &:read) + keys, message = out.lines(chomp: true) + + assert_equal("[]", keys, out) + assert_match(/no codec provider is registered/, message) + assert_match(/Dexpace::Serde\.install/, message) + refute_match(/json|oj/i, message, "SEAM-2") end test "install refuses a codec that does not implement the seam" do @@ -56,6 +69,18 @@ class DexpaceSerdeTest < DexpaceTestCase assert_match(/must implement the seam/, error.message) end + # After the block the OVERRIDE is gone. What resolves then depends on the process: nothing, in + # a process that required core alone (a SeamError); the registered adapter's codec, under + # `rake test:gems`, where dexpace-serde-json has registered :json. Registry#swap restores + # `resolved` and deliberately never `factories` (a registration is a monotonic require-time + # fact), so the assertion is that the swapped-in codec is no longer what resolves -- converted by + # phase 7a from "nothing resolves", a pin its registration invalidated. + def resolved_after_swap + Dexpace::Serde.resolve + rescue Dexpace::SeamError + :unresolved + end + test "swap scopes a codec override to its block" do codec = FakeCodec.new @@ -63,7 +88,7 @@ class DexpaceSerdeTest < DexpaceTestCase assert_same(codec, Dexpace::Serde.resolve) end - assert_raises(Dexpace::SeamError) { Dexpace::Serde.resolve } + refute_same(codec, resolved_after_swap) end test "install goes through the module, returns it, and is scoped by an enclosing swap" do @@ -74,7 +99,7 @@ class DexpaceSerdeTest < DexpaceTestCase assert_same(codec, Dexpace::Serde.resolve) end - assert_raises(Dexpace::SeamError) { Dexpace::Serde.resolve } + refute_same(codec, resolved_after_swap) end # Scoped inside a swap and cleaned out of the private registry afterwards, for the reason diff --git a/gems/dexpace-serde-json/dexpace-serde-json.gemspec b/gems/dexpace-serde-json/dexpace-serde-json.gemspec index 9bb0a15..1db9175 100644 --- a/gems/dexpace-serde-json/dexpace-serde-json.gemspec +++ b/gems/dexpace-serde-json/dexpace-serde-json.gemspec @@ -12,8 +12,7 @@ Gem::Specification.new do |spec| spec.summary = "The reference wire codec for dexpace, over Ruby's json." spec.description = <<~TEXT The reference wire codec for the dexpace HTTP-client toolkit, implemented over Ruby's json - gem. It depends on dexpace-core and, once the codec lands, on json >= 2.19.9 and nothing - else. + gem. It depends on dexpace-core and on json >= 2.19.9 and nothing else. TEXT spec.homepage = "https://github.com/dexpace/ruby-sdk" spec.license = "MIT" @@ -32,7 +31,13 @@ Gem::Specification.new do |spec| spec.add_dependency "dexpace-core", DexpaceVersions.core_constraint - # NFR-2: dexpace-core plus at most one third-party gem. The third-party half of this - # adapter's budget is declared by the phase that writes the code needing it (design P0-9); - # `rake gates:gemspec_audit` enforces the whole budget either way. + # NFR-2: dexpace-core plus at most one third-party gem, and this is the one. The floor is the + # first json version with JSON::Coder -- the per-instance, freezable, thread-safe engine + # SERDE-26 and SERDE-29 rest on (phase 7a's P7-4) -- and the one carrying the 2026 advisories. + # THIS LINE IS THE ONLY PLACE IN THE REPOSITORY THAT FLOOR MAY BE STATED (CLAUDE.md's hard rule, + # design §3.4): core's require allowlist denies `json` by name, `rake gates:gemspec_audit` + # enforces the budget, and `rake gates:require_allowlist` permits `require "json"` under this + # gem's lib/ only because this line declares it. The entry file re-asserts the same number at + # require time for an unbundled consumer (P7-7). + spec.add_dependency "json", ">= 2.19.9" end diff --git a/gems/dexpace-serde-json/lib/dexpace/serde/json.rb b/gems/dexpace-serde-json/lib/dexpace/serde/json.rb index c4ec0ae..7717f36 100644 --- a/gems/dexpace-serde-json/lib/dexpace/serde/json.rb +++ b/gems/dexpace-serde-json/lib/dexpace/serde/json.rb @@ -1,20 +1,83 @@ # frozen_string_literal: true # SPDX-License-Identifier: MIT +require "json" +require "dexpace" + require_relative "json/version" module Dexpace # Wire codecs: the serde seam's shipped implementations. The seam contract itself lives in # dexpace-core. module Serde - # The reference wire codec, over Ruby's `json` default gem. Phase 0 ships the namespace and - # VERSION only; the codec itself lands in phase 7. + # The reference wire codec, over Ruby's `json` gem: the seam's six methods on + # Dexpace::Serde::JSON::Codec, the two factories here, the ISO-8601 encoder default and the + # tri-state wiring inherited from core's Native walk, and the `json >= 2.19.9` floor -- declared + # in this gem's gemspec, the only place it may be stated, and re-asserted below at require time. # # CAUTION: this module shadows ::JSON inside its own namespace. An unqualified `JSON.parse` # written anywhere under `Dexpace::Serde::JSON` resolves to this module, not to Ruby's, and # fails with a confusing NoMethodError. Every reference to Ruby's JSON from inside here is - # written `::JSON`. + # written `::JSON`, and the Dexpace/QualifiedCoreConstant cop refuses the bare spelling. module JSON + # The floor this gem declares in its gemspec and asserts at require time (P7-7). It is the + # first json with JSON::Coder -- the per-instance, freezable, thread-safe engine SERDE-26's + # private copy and SERDE-29's sharing rest on -- and the one carrying the 2026 advisories. + # `bundler-audit` enforces the floor for a BUNDLED consumer in this repository's CI; it never + # runs in a consumer's process, and an unbundled `require "dexpace/serde/json"` on a stock + # Ruby 3.3 or 3.4 activates the interpreter's default json (2.7.2 / 2.9.1), which has no + # Coder at all, while a stock 4.0 activates 2.18.0, which has one and is still below the + # floor. Without this assertion the failure would be a NameError deep inside a codec, or no + # failure and an unpatched parser. + MINIMUM_JSON_VERSION = "2.19.9" + + # The core this adapter was built against, as design §2.4's registration-time version-skew + # guard wants it: a two-segment pessimistic requirement, and never Dexpace::VERSION -- + # phase 2's Registry accepts only `~> M.N` and raises on "0.0.0", and the running core's own + # version is a tautology. Public so a consumer debugging a skew failure can read the + # constraint; equal to the gemspec's dexpace-core requirement, which the suite asserts. + REQUIRED_CORE = "~> 0.0" + + class << self + # SERDE-25's default-configuration factory: a FRESH, independently configured codec on + # every call, never a shared instance, so an application that reconfigures the codec it + # was handed cannot change the one another part of the process is using. This is also the + # registry's factory (design §3.6). + # + # @return [Dexpace::Serde::JSON::Codec] + def default = Codec.build + + # A codec over caller options. One positional Hash rather than a `**` splat, as + # Dexpace::Model#with is spelled: Ruby passes keywords to a method declaring none as one + # positional Hash, so `JSON.build(max_nesting: 4)` reads as it should while the empty call + # allocates nothing (Dexpace/NoKeywordSplat) and an unknown key is the SDK's own + # InvalidArgumentError rather than a keyword error whose shape differs between json 2.19.9 + # (which swallowed an unknown Coder option) and 3.0 (which refuses it). + # + # @param options [Hash{Symbol => Object}] see Codec.build + # @return [Dexpace::Serde::JSON::Codec] + # @raise [Dexpace::InvalidArgumentError] on an unknown option + def build(options = nil) = Codec.build(options) + end end end end + +# P7-7: the floor, asserted before the codec is even loaded, naming itself. A Gem::Version +# comparison needs no require. This runs at the TOP LEVEL, outside `module Dexpace`, so a bare +# `JSON` here is Ruby's (the shadowing hazard is lexical) and the cop set wants it unqualified. +if Gem::Version.new(JSON::VERSION) < Gem::Version.new(Dexpace::Serde::JSON::MINIMUM_JSON_VERSION) + raise Dexpace::SeamError, + "dexpace-serde-json requires json >= #{Dexpace::Serde::JSON::MINIMUM_JSON_VERSION}; " \ + "json #{JSON::VERSION} is active. Add `gem \"json\", \">= " \ + "#{Dexpace::Serde::JSON::MINIMUM_JSON_VERSION}\"` to the bundle, or activate it before " \ + "requiring this gem." +end + +require_relative "json/codec" + +# Design §3.6's require-time self-registration, with the version-skew guard on `core:` (design +# §2.4) -- spent here for the first time by an adapter with a third-party dependency. The factory +# is `.default`, so every resolution is a fresh codec (SERDE-25). +Dexpace::Serde.register(:json, -> { Dexpace::Serde::JSON.default }, + core: Dexpace::Serde::JSON::REQUIRED_CORE,) diff --git a/gems/dexpace-serde-json/lib/dexpace/serde/json/codec.rb b/gems/dexpace-serde-json/lib/dexpace/serde/json/codec.rb new file mode 100644 index 0000000..23bcb89 --- /dev/null +++ b/gems/dexpace-serde-json/lib/dexpace/serde/json/codec.rb @@ -0,0 +1,291 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require "json" +require "date" +require "time" +require "dexpace" + +module Dexpace + module Serde + module JSON + # The seam's six methods over one private ::JSON::Coder (SEAM-19–SEAM-21, SERDE-1–SERDE-4, + # SERDE-9–SERDE-12, SERDE-25, SERDE-26, SERDE-29). A plain class, not a Data: it holds an + # engine that is an implementation detail with no value semantics. Frozen at the end of + # `#initialize`, `.new` private, `.build` the one constructor and `.default` a FRESH instance + # per call (SERDE-25). + # + # SERDE-26 is satisfied literally rather than through §11.18's fallback clause (P7-4): the + # constructor takes OPTIONS, never a caller's coder, so "built around a caller-supplied codec + # instance" never happens, and each instance owns a private ::JSON::Coder built from its own + # options -- json 2.19.9's per-instance, freezable, thread-safe engine (verified), which is + # also what makes one frozen codec safe to share across workers with no per-type cache to + # publish (SERDE-29): the witness is supplied per call and nothing is memoised by type. + # + # The options are validated against a frozen allowlist -- `max_nesting`, `allow_nan`, + # `allow_duplicate_key`, `script_safe` and `encoders` -- and an unknown key is the SDK's own + # InvalidArgumentError, because the library's own behaviour differs across the supported + # range: json 2.19.9 SWALLOWS an unknown Coder option (a forwarded typo silently configures a + # different codec) and json 3.0 refuses it with a keyword error. Two options are set by this + # class and not by the caller: `strict: true` always, belt and braces beside core's Native + # walk, which refuses every non-native value before the generator sees it -- verified fact 3 + # is that ::JSON.generate otherwise stringifies an Object silently; and + # `allow_duplicate_key: false` unless the caller opts in, because a duplicate key is + # accepted-last-wins on json 2.9, a WARNING on 2.19.9 and a ParserError on 3.0, and the + # explicit option makes it one DeserializationError across the range. `encoders:` never + # reaches the Coder (json 3.0 refuses the keyword): it replaces the default encoder table, + # which renders ::Time, ::DateTime and ::Date as ISO-8601 through core's Instant witness + # (SERDE-24; design §3.4 makes the wiring the adapter's, not core's). + # + # Every reference to Ruby's JSON is `::JSON`: inside this namespace a bare `JSON` is the + # adapter module (phase 0's shadowing caution; Dexpace/QualifiedCoreConstant enforces it). + # `::JSON::Coder#load` is `::JSON.parse`'s configured form and is NOT `::JSON.load`, which + # design §3.4 bans for its `create_additions` hazard (CVE-2020-10663); the two share four + # letters and nothing else. + class Codec + # The option validation, kept beside the class it serves: the allowlist, and the shape of + # `encoders:`. Private, and not a second public constant in this file. + module Options + extend self + + # The options a caller may pass (see the class comment for why an allowlist). + ALLOWED = %i[max_nesting allow_nan allow_duplicate_key script_safe encoders].freeze + + # @param options [Hash, nil] + # @return [Hash{Symbol => Object}] the options, or an empty Hash for nil + # @raise [Dexpace::InvalidArgumentError] on a non-Hash or an unknown key + def table!(options) + return {} if options.nil? + unless options.is_a?(::Hash) + raise InvalidArgumentError, "options must be a Hash, got #{options.class}" + end + + unknown = options.keys.reject { |key| ALLOWED.include?(key) } + return options if unknown.empty? + + raise InvalidArgumentError, + "unknown codec option(s) #{unknown.map(&:inspect).join(", ")}; the accepted " \ + "options are #{ALLOWED.map(&:inspect).join(", ")}" + end + + # @param encoders [Hash{Module => #call}] + # @return [Hash{Module => #call}] a frozen copy + # @raise [Dexpace::InvalidArgumentError] on anything else + def encoders!(encoders) + valid = encoders.is_a?(::Hash) && + encoders.all? { |k, v| k.is_a?(::Module) && v.respond_to?(:call) } + unless valid + raise InvalidArgumentError, + "encoders must be a Hash of Class to #call, got #{encoders.class}" + end + + encoders.frozen? ? encoders : encoders.dup.freeze + end + end + private_constant :Options + + # SERDE-24's adapter default: date and time values as ISO-8601 strings through core's + # Instant, never epoch numbers. Exact-class lookup finds DateTime before its superclass + # Date. Replaceable through `encoders:`. + DEFAULT_ENCODERS = { + ::Time => ->(time) { Instant.dexpace_dump(time) }, + ::DateTime => ->(datetime) { Instant.dexpace_dump(datetime.to_time) }, + ::Date => :iso8601.to_proc, + }.freeze + private_constant :DEFAULT_ENCODERS + + # SEAM-19/SERDE-2: the media type this codec produces, one frozen value. + MEDIA_TYPE = MediaType.parse("application/json") + private_constant :MEDIA_TYPE + + private_class_method :new + + # The one constructor. One positional Hash rather than a `**` splat, as Dexpace::Model#with + # is spelled: Ruby passes keywords to a method declaring none as one positional Hash, so + # `Codec.build(max_nesting: 4)` reads as it should while the empty call allocates nothing + # (Dexpace/NoKeywordSplat) and validation stays this class's. + # + # @param options [Hash{Symbol => Object}, nil] `max_nesting:` (Integer), `allow_nan:`, + # `allow_duplicate_key:`, `script_safe:` (booleans), `encoders:` (a Hash of Class to + # `#call(value) -> native`, replacing the ISO-8601 default table); a nil value means the + # default + # @return [Codec] frozen + # @raise [Dexpace::InvalidArgumentError] on a non-Hash, an unknown or non-Symbol key, or an + # `encoders:` that is not a Hash of Class to callable + def self.build(options = nil) + new(options) + end + + # SERDE-25: a fresh, independently configured instance on every call. + # + # @return [Codec] + def self.default = build + + def initialize(options) + table = Options.table!(options) + @encoders = Options.encoders!(table.fetch(:encoders, nil) || DEFAULT_ENCODERS) + # allow_duplicate_key: false unless the caller says otherwise; strict: true always. + json_options = { allow_duplicate_key: false }.merge(table.except(:encoders).compact) + @coder = ::JSON::Coder.new(**json_options, strict: true) + freeze + end + + # SEAM-19/SERDE-2: `application/json`, as a Dexpace::MediaType. + # + # @return [Dexpace::MediaType] + def media_type = MEDIA_TYPE + + # SEAM-20's string profile: the value walked to native form by core's Native (SERDE-15, + # SERDE-19, SERDE-20, SERDE-9's loud refusal) and generated as one UTF-8 String. + # + # @param value [Object] + # @return [String] UTF-8, fresh + # @raise [Dexpace::Serde::SerializationError] on an unencodable value, chaining the + # library's error when the generator raised (SERDE-9, SERDE-10) + def dump_string(value) + native = Native.of(value, encoders: @encoders) + text = @coder.dump(native) + text.force_encoding(::Encoding::UTF_8) unless text.encoding == ::Encoding::UTF_8 + text + rescue ::JSON::JSONError => error + # Inside the rescue, so Ruby chains the library's error as #cause (SERDE-9). + raise SerializationError, "the value could not be encoded as JSON: #{error.message}" + end + + # SEAM-20's byte-array profile: the same bytes, BINARY-tagged (§10.13). + # + # @param value [Object] + # @return [String] Encoding::BINARY, fresh + # @raise [Dexpace::Serde::SerializationError] + def dump_bytes(value) = dump_string(value).b + + # SEAM-20's streaming profile: writes the bytes into a caller-owned sink and NEVER closes it + # (SERDE-3). + # + # @param value [Object] + # @param sink [#write] Dexpace::IO::_Sink-shaped + # @return [Integer] the byte count written + # @raise [Dexpace::Serde::SerializationError] + # @raise [Dexpace::InvalidArgumentError] when `sink` does not answer #write + def dump_to(value, sink) + unless sink.respond_to?(:write) + raise InvalidArgumentError, "sink must respond to #write, got #{sink.class}" + end + + bytes = dump_bytes(value) + sink.write(bytes) + bytes.bytesize + end + + # SEAM-20's buffer profile (SERDE-4): writes at `offset` into a mutable BINARY String and + # answers the byte count. A range failure is ::IndexError -- distinct from the serde type + # and chaining nothing -- and bytes outside the written region are untouched. The fit is + # checked EXPLICITLY, because `String#[]=` with an in-range offset and an over-long payload + # silently GROWS the target instead of raising (verified fact 12), which is the one + # behaviour SERDE-4 exists to forbid. The payload is encoded before the check because its + # length is not knowable otherwise, so a generator failure surfaces as SerializationError + # and an overflow as IndexError, in that order. Ruby's IO::Buffer is refused (P7-5): it + # warns through Warning.warn on construction at every level, and its `#set_string` raises + # ArgumentError where `String#[]=` raises IndexError. + # + # @param value [Object] + # @param buffer [String] mutable, Encoding::BINARY + # @param offset [Integer] the start position + # @return [Integer] the byte count written + # @raise [::IndexError] when `offset` is out of range or the payload does not fit; `#cause` + # is nil even when raised inside a caller's rescue + # @raise [Dexpace::Serde::SerializationError] on an unencodable value + # @raise [Dexpace::InvalidArgumentError] on a frozen, non-BINARY or non-String buffer, or a + # non-Integer offset -- a wrong KIND of argument, not a wrong range (3a's IO-3 precedent) + def dump_into(value, buffer, offset: 0) + buffer!(buffer, offset) + encoded = dump_bytes(value) + size = encoded.bytesize + if offset.negative? || offset > buffer.bytesize || offset + size > buffer.bytesize + # SERDE-4: distinct from the serde type and NOT chaining the caller's in-flight error + # (pipeline/7ce4431d's spelling; verified fact 11). + raise ::IndexError, + "#{size} bytes at offset #{offset} do not fit a #{buffer.bytesize}-byte buffer", + cause: nil + end + + buffer[offset, size] = encoded + size + end + + # SEAM-21's decode: drains the caller's source to EOF, validates the text as UTF-8, parses + # it with the private engine and hands the parsed value to the witness with a root + # DecodeContext naming it. Closes nothing (SERDE-3; design §10.12's third ownership rule, + # which phase 3 left to this layer). + # + # The source is read with 3a's `#read_utf8` -- unconditionally UTF-8, because RFC 8259 §8.1 + # fixes JSON text as UTF-8 for interchange, and this method takes a source, not a response, + # so it has no declared charset to consult -- guarded incrementally by + # Dexpace::IO.max_materialized_bytes (P7-1: the whole text IS materialised; a body above the + # ceiling raises Dexpace::StreamError, an ::IOError, unwrapped). A raw IO answering `#read` + # is wrapped in a BufferedSource for the same read and the same guard; the wrapper is + # dropped, never closed, so the caller's IO stays open. The UTF-8 validation is P7-6: 3a's + # `#read_utf8` retags without validating and `::JSON.parse` accepts invalid UTF-8, returning + # a UTF-8-tagged String whose `#valid_encoding?` is false, so without this line a caller + # receives a String that claims an encoding it does not have. + # + # The parse is rescued as `::JSON::JSONError` and nothing wider: its ancestry is + # [ParserError, JSONError, StandardError] with IOError nowhere in it (verified fact 4), so a + # Dexpace::StreamError structurally cannot be caught here and SERDE-12 holds without a + # discipline. The witness's own DeserializationError passes through unwrapped. + # + # @param source [Dexpace::IO::BufferedSource, #read] a caller-owned stream + # @param witness [Object] the target: a class answering .dexpace_load, or a combinator + # @return [Object] the witness's decode + # @raise [Dexpace::Serde::DeserializationError] on invalid UTF-8, malformed JSON (chaining + # the parser's error), or a shape mismatch (from the witness, naming the target) + # @raise [Dexpace::StreamError] unwrapped, from the source + # @raise [Dexpace::InvalidArgumentError] on a non-witness or a non-stream source + def load(source, witness) + Dexpace::Serde.witness!(witness) + text = drain(source) + unless text.valid_encoding? + raise DeserializationError, + "the payload is not valid UTF-8 (RFC 8259 §8.1); refusing to parse it" + end + + parsed = parse(text) + # SERDE-13: the root frame carries the TARGET so a top-level null names Pet, not Hash; + # nothing screens for nil here because SERDE-20's Tristate.of and Nullable.of want one. + witness.dexpace_load(parsed, DecodeContext.root(target: witness)) + end + + private + + # P7-5 and 3a's precedent: a wrong KIND of argument is an argument error, never a range one. + def buffer!(buffer, offset) + unless buffer.is_a?(::String) && !buffer.frozen? && buffer.encoding == ::Encoding::BINARY + raise InvalidArgumentError, "buffer must be a mutable Encoding::BINARY String" + end + return if offset.is_a?(::Integer) + + raise InvalidArgumentError, "offset must be an Integer, got #{offset.class}" + end + + def drain(source) + return source.read_utf8 if source.respond_to?(:read_utf8) + if source.respond_to?(:read) || source.respond_to?(:readpartial) + return Dexpace::IO::BufferedSource.wrapping(source).read_utf8 + end + + raise InvalidArgumentError, + "source must be a Dexpace::IO::BufferedSource or an IO answering #read, " \ + "got #{source.class}" + end + + # ::JSON::Coder#load, which is ::JSON.parse's configured form and NOT ::JSON.load. + def parse(text) + @coder.load(text) + rescue ::JSON::JSONError => error + # Inside the rescue, so Ruby chains the parser's error as #cause (SERDE-9, SERDE-27). + raise DeserializationError, "malformed JSON: #{error.message}" + end + end + end + end +end diff --git a/gems/dexpace-serde-json/sig/dexpace/serde/json.rbs b/gems/dexpace-serde-json/sig/dexpace/serde/json.rbs index 51a7c86..6bad8ff 100644 --- a/gems/dexpace-serde-json/sig/dexpace/serde/json.rbs +++ b/gems/dexpace-serde-json/sig/dexpace/serde/json.rbs @@ -1,6 +1,13 @@ module Dexpace module Serde + # The reference wire codec over Ruby's json: the floor it asserts (P7-7), the core constraint + # it registers against, and the two factories. module JSON + MINIMUM_JSON_VERSION: String + REQUIRED_CORE: String + + def self.default: () -> Codec + def self.build: (?Hash[Symbol, untyped]? options) -> Codec end end end diff --git a/gems/dexpace-serde-json/sig/dexpace/serde/json/codec.rbs b/gems/dexpace-serde-json/sig/dexpace/serde/json/codec.rbs new file mode 100644 index 0000000..2e71e9e --- /dev/null +++ b/gems/dexpace-serde-json/sig/dexpace/serde/json/codec.rbs @@ -0,0 +1,44 @@ +module Dexpace + module Serde + module JSON + # The seam's six methods over one private ::JSON::Coder. No ::JSON constant appears in this + # signature (NFR-11): the engine is an untyped private ivar with no reader, and rbs's stdlib + # json signatures (4.2.0) declare no JSON::Coder to name in any case. + class Codec + # Three private_constants with no visibility in RBS; declared so Steep can type the + # constructor. The privacy lives in lib/dexpace/serde/json/codec.rb. + module Options + ALLOWED: Array[Symbol] + + def self?.table!: (untyped options) -> Hash[Symbol, untyped] + def self?.encoders!: (untyped encoders) -> Hash[Module, untyped] + end + + DEFAULT_ENCODERS: Hash[Module, untyped] + MEDIA_TYPE: Dexpace::MediaType + + @coder: untyped + @encoders: Hash[Module, untyped] + + private def self.new: (Hash[Symbol, untyped]? options) -> instance + def initialize: (Hash[Symbol, untyped]? options) -> void + + def self.build: (?Hash[Symbol, untyped]? options) -> Codec + def self.default: () -> Codec + + def media_type: () -> Dexpace::MediaType + def dump_string: (untyped value) -> String + def dump_bytes: (untyped value) -> String + def dump_to: (untyped value, untyped sink) -> Integer + def dump_into: (untyped value, String buffer, ?offset: Integer) -> Integer + def load: (untyped source, untyped witness) -> untyped + + private + + def buffer!: (untyped buffer, untyped offset) -> void + def drain: (untyped source) -> String + def parse: (String text) -> untyped + end + end + end +end diff --git a/gems/dexpace-serde-json/test/dexpace/serde/json_test.rb b/gems/dexpace-serde-json/test/dexpace/serde/json_test.rb index 2ecd510..fa0ecb9 100644 --- a/gems/dexpace-serde-json/test/dexpace/serde/json_test.rb +++ b/gems/dexpace-serde-json/test/dexpace/serde/json_test.rb @@ -2,17 +2,23 @@ # SPDX-License-Identifier: MIT require_relative "../../test_helper" -require "json" # NFR-15: the version a published artifact reports at runtime is the real one. Styleguide 12.7: -# no constant outside Dexpace::. +# no constant outside Dexpace::. NFR-2: this gem's one third-party dependency, `json >= 2.19.9`, +# declared here and nowhere else (design §3.4). P7-7: the floor asserted at require time, because +# bundler-audit runs in this repository's CI and never in a consumer's process. class JSONTest < DexpaceTestCase - # The top-level namespace is snapshotted around the require, so "defines nothing outside - # Dexpace" holds whether this file loads alone or after the other five gems in one - # `rake test:gems` process, where Dexpace already exists. The intermediate namespace is - # snapshotted the same way: a sibling this entry file placed beside its own constant -- - # `Dexpace::Serde::Shared` -- is outside the gem's namespace and inside nothing this suite - # would otherwise look at. + # The namespaces are snapshotted around the require, so "defines nothing outside Dexpace" holds + # whether this file loads alone or after the other five gems in one `rake test:gems` process, + # where Dexpace already exists. Since phase 7a the entry file requires core (SeamError, + # Serde.register, Native, MediaType) and `json`, `time` and `date` (the ISO-8601 defaults), so + # those are loaded FIRST -- as core's own smoke suite preloads its stdlib features -- and the + # delta the assertions read is exactly what this gem adds: `:JSON` beside its namespace, and + # nothing at the top level or directly under Dexpace. + require "dexpace" + require "json" + require "time" + require "date" TOP_LEVEL_BEFORE = Object.constants NAMESPACE_BEFORE = defined?(Dexpace) ? Dexpace.constants(false) : [] SIBLINGS_BEFORE = defined?(Dexpace::Serde) ? Dexpace::Serde.constants(false) : [] @@ -21,22 +27,24 @@ class JSONTest < DexpaceTestCase NAMESPACE_ADDED = (Dexpace.constants(false) - NAMESPACE_BEFORE).freeze SIBLINGS_ADDED = (Dexpace::Serde.constants(false) - SIBLINGS_BEFORE).freeze + GEMSPEC = File.expand_path("../../../dexpace-serde-json.gemspec", __dir__) + + def spec = Gem::Specification.load(GEMSPEC) + test "defines a semver VERSION string" do assert_match(/\A\d+\.\d+\.\d+\z/, Dexpace::Serde::JSON::VERSION) end test "the VERSION matches the gemspec this gem is built from" do - gemspec = File.expand_path("../../../dexpace-serde-json.gemspec", __dir__) - spec = Gem::Specification.load(gemspec) - assert_equal(spec.version.to_s, Dexpace::Serde::JSON::VERSION) end test "defines nothing outside the Dexpace namespace" do - assert_empty(TOP_LEVEL_ADDED - [:Dexpace], "top-level constants added by the entry file") - assert_empty(NAMESPACE_ADDED - %i[Serde], "constants added directly under Dexpace") + assert_empty(TOP_LEVEL_ADDED, "top-level constants added by the entry file") + assert_empty(NAMESPACE_ADDED, "constants added directly under Dexpace") assert_empty(SIBLINGS_ADDED - %i[JSON], "constants added beside this gem's namespace") - assert_equal(%i[VERSION], Dexpace::Serde::JSON.constants(false).sort) + assert_equal(%i[Codec MINIMUM_JSON_VERSION REQUIRED_CORE VERSION], + Dexpace::Serde::JSON.constants(false).sort,) end # The module shadows ::JSON inside its own namespace (see the CAUTION in the entry @@ -46,4 +54,52 @@ class JSONTest < DexpaceTestCase assert_equal("Dexpace::Serde::JSON", Dexpace::Serde::JSON.name) refute_same(::JSON, Dexpace::Serde::JSON) end + + test "NFR-2: the gemspec declares dexpace-core plus exactly one third-party gem, floored" do + names = spec.runtime_dependencies.map(&:name).sort + + assert_equal(%w[dexpace-core json], names) + assert_equal([">= 2.19.9"], + spec.runtime_dependencies.find { |d| d.name == "json" }.requirements_list,) + end + + # P7-7. bundler-audit enforces the floor for a BUNDLED consumer in this repository's CI; it never + # runs in a consumer's process. An unbundled `require "dexpace/serde/json"` on Ruby 3.4.10 + # activates the interpreter's default json 2.9.1, which has no JSON::Coder at all -- so without + # this the failure is a NameError deep inside a codec, and the 2026 advisories the floor exists + # for are silently unpatched. The constant and the gemspec state one number. + test "P7-7: the floor is asserted at require time and names itself" do + assert_equal("2.19.9", Dexpace::Serde::JSON::MINIMUM_JSON_VERSION) + assert_operator(Gem::Version.new(::JSON::VERSION), :>=, + Gem::Version.new(Dexpace::Serde::JSON::MINIMUM_JSON_VERSION),) + assert_equal([">= #{Dexpace::Serde::JSON::MINIMUM_JSON_VERSION}"], + spec.runtime_dependencies.find { |d| d.name == "json" }.requirements_list,) + end + + test "the adapter registers itself against the seam under :json" do + assert_includes(Dexpace::Serde.registered_keys, :json) + end + + # The registry's core: argument is a TWO-SEGMENT PESSIMISTIC REQUIREMENT, never Dexpace::VERSION. + # Phase 2's Registry#assert_core_version! matches it against /\A~>\s*(\d+)\.(\d+)\z/ and raises + # Dexpace::InvalidArgumentError on anything else, so `core: Dexpace::VERSION` ("0.0.0") would make + # `require "dexpace/serde/json"` raise at load. It must also AGREE with the string the gemspec + # declares for dexpace-core -- the agreement gates:gemspec_audit checks from one side and nothing + # checked from the other. + test "REQUIRED_CORE is a ~> constraint and equals the gemspec's dexpace-core requirement" do + declared = spec.runtime_dependencies.find { |d| d.name == "dexpace-core" }.requirements_list + + assert_match(/\A~>\s*\d+\.\d+\z/, Dexpace::Serde::JSON::REQUIRED_CORE) + assert_equal(declared, [Dexpace::Serde::JSON::REQUIRED_CORE]) + end + + test "the module's two factories answer a fresh conforming codec each" do + a = Dexpace::Serde::JSON.default + b = Dexpace::Serde::JSON.build(max_nesting: 8) + + assert(Dexpace::Serde.conforms?(a)) + assert(Dexpace::Serde.conforms?(b)) + refute_same(a, Dexpace::Serde::JSON.default) + assert_instance_of(Dexpace::Serde::JSON::Codec, b) + end end diff --git a/rbs_collection.yaml b/rbs_collection.yaml index 63529e6..46677a6 100644 --- a/rbs_collection.yaml +++ b/rbs_collection.yaml @@ -1,6 +1,8 @@ # Third-party RBS, resolved from the gems the bundle actually holds. No third-party row exists -# in phase 0 because no gemspec declares a third-party dependency yet: json arrives with -# dexpace-serde-json's codec in phase 7, net-http and async-http with the transports in phase 8, +# yet: phase 7a's dexpace-serde-json declares json, but json's signatures are rbs's own stdlib set +# (`rbs collection install` already resolves `json` with `source: type: stdlib`) and json 3.0.2 +# ships no sig/ of its own, so the codec's one `JSON::Coder` reference is settled in the Steepfile's +# :serde_json target instead of here; net-http and async-http arrive with the transports in phase 8, # and each adds its own row here then. Core never gets one -- its allowlist is stdlib only, and # stdlib signatures ship with rbs itself. sources: diff --git a/test/fixtures/surface/dexpace-core.txt b/test/fixtures/surface/dexpace-core.txt index 14f0a11..cb5adb6 100644 --- a/test/fixtures/surface/dexpace-core.txt +++ b/test/fixtures/surface/dexpace-core.txt @@ -173,6 +173,7 @@ Dexpace::Body.chunked Dexpace::Body.file Dexpace::Body.form Dexpace::Body.multipart +Dexpace::Body.serialized Dexpace::Body.stream Dexpace::Body.string Dexpace::Body::MAX_BUFFERED_ERROR_BODY_BYTES : Integer @@ -1187,9 +1188,81 @@ Dexpace::Serde.register Dexpace::Serde.registered_keys Dexpace::Serde.resolve Dexpace::Serde.swap +Dexpace::Serde.witness! +Dexpace::Serde.witness? +Dexpace::Serde::BOOLEAN : Dexpace::Serde::Scalars::Scalar +Dexpace::Serde::DUMP_METHOD : Symbol +Dexpace::Serde::DecodeContext +Dexpace::Serde::DecodeContext#array! +Dexpace::Serde::DecodeContext#at +Dexpace::Serde::DecodeContext#boolean! +Dexpace::Serde::DecodeContext#error! +Dexpace::Serde::DecodeContext#float! +Dexpace::Serde::DecodeContext#integer! +Dexpace::Serde::DecodeContext#object! +Dexpace::Serde::DecodeContext#path +Dexpace::Serde::DecodeContext#pointer +Dexpace::Serde::DecodeContext#present! +Dexpace::Serde::DecodeContext#string! +Dexpace::Serde::DecodeContext#target +Dexpace::Serde::DecodeContext.build +Dexpace::Serde::DecodeContext.root +Dexpace::Serde::DecodingHandler +Dexpace::Serde::DecodingHandler#call +Dexpace::Serde::DecodingHandler#serde +Dexpace::Serde::DecodingHandler#witness +Dexpace::Serde::DecodingHandler.build Dexpace::Serde::DeserializationError Dexpace::Serde::Error +Dexpace::Serde::Instant +Dexpace::Serde::Instant#dexpace_dump +Dexpace::Serde::Instant#dexpace_load +Dexpace::Serde::List +Dexpace::Serde::List#dexpace_load +Dexpace::Serde::List#element +Dexpace::Serde::List.build +Dexpace::Serde::List.of +Dexpace::Serde::Map +Dexpace::Serde::Map#dexpace_load +Dexpace::Serde::Map#key +Dexpace::Serde::Map#value +Dexpace::Serde::Map.build +Dexpace::Serde::Map.of +Dexpace::Serde::Native +Dexpace::Serde::Native#of +Dexpace::Serde::Nullable +Dexpace::Serde::Nullable#dexpace_load +Dexpace::Serde::Nullable#element +Dexpace::Serde::Nullable.build +Dexpace::Serde::Nullable.of +Dexpace::Serde::OMIT : Dexpace::Serde::Omit Dexpace::Serde::SerializationError +Dexpace::Serde::StatusAwareHandler +Dexpace::Serde::StatusAwareHandler#call +Dexpace::Serde::StatusAwareHandler#factory +Dexpace::Serde::StatusAwareHandler#serde +Dexpace::Serde::StatusAwareHandler#witness +Dexpace::Serde::StatusAwareHandler.build +Dexpace::Serde::Tristate +Dexpace::Serde::Tristate#absent? +Dexpace::Serde::Tristate#fold +Dexpace::Serde::Tristate#null? +Dexpace::Serde::Tristate#present? +Dexpace::Serde::Tristate#value_or_nil +Dexpace::Serde::Tristate.absent +Dexpace::Serde::Tristate.from_nullable +Dexpace::Serde::Tristate.null +Dexpace::Serde::Tristate.of +Dexpace::Serde::Tristate.present +Dexpace::Serde::Tristate::ABSENT : Dexpace::Serde::Tristate::Absent +Dexpace::Serde::Tristate::NULL : Dexpace::Serde::Tristate::Null +Dexpace::Serde::Tristate::Present +Dexpace::Serde::Tristate::Present#dexpace_dump +Dexpace::Serde::Tristate::Present#present? +Dexpace::Serde::Tristate::Present#value +Dexpace::Serde::Tristate::Present#value_or_nil +Dexpace::Serde::Tristate::Present.build +Dexpace::Serde::WITNESS_METHOD : Symbol Dexpace::Status Dexpace::Status#canonical_name Dexpace::Status#client_error? diff --git a/test/fixtures/surface/dexpace-serde-json.txt b/test/fixtures/surface/dexpace-serde-json.txt index 08ed3b4..8acba62 100644 --- a/test/fixtures/surface/dexpace-serde-json.txt +++ b/test/fixtures/surface/dexpace-serde-json.txt @@ -1,2 +1,15 @@ Dexpace::Serde::JSON +Dexpace::Serde::JSON.build +Dexpace::Serde::JSON.default +Dexpace::Serde::JSON::Codec +Dexpace::Serde::JSON::Codec#dump_bytes +Dexpace::Serde::JSON::Codec#dump_into +Dexpace::Serde::JSON::Codec#dump_string +Dexpace::Serde::JSON::Codec#dump_to +Dexpace::Serde::JSON::Codec#load +Dexpace::Serde::JSON::Codec#media_type +Dexpace::Serde::JSON::Codec.build +Dexpace::Serde::JSON::Codec.default +Dexpace::Serde::JSON::MINIMUM_JSON_VERSION : String +Dexpace::Serde::JSON::REQUIRED_CORE : String Dexpace::Serde::JSON::VERSION : String From b966d50da04108e6ba3a016ce6be82aaf4c5ade1 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Sun, 20 Sep 2026 13:29:04 +0300 Subject: [PATCH 2/3] fix: leave rbs_collection.yaml as main has it Review round 0, R0-1. The feat commit rewrote three lines of the file's header comment to correct its stale "json arrives with the codec in phase 7" sentence. The file is a shared one this phase's brief lists out of bounds -- phase 8a adds the first row to it -- and no gate reads the comment, so the correction rode a scope breach for nothing. Restore the file to main's content; the stale sentence is routed by date and content to phase 10's inbound list on the docs branch, for whichever lane adds the first row to close. The Steepfile's :serde_json relaxation, which is what settles the JSON::Coder reference, is unchanged. --- rbs_collection.yaml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/rbs_collection.yaml b/rbs_collection.yaml index 46677a6..63529e6 100644 --- a/rbs_collection.yaml +++ b/rbs_collection.yaml @@ -1,8 +1,6 @@ # Third-party RBS, resolved from the gems the bundle actually holds. No third-party row exists -# yet: phase 7a's dexpace-serde-json declares json, but json's signatures are rbs's own stdlib set -# (`rbs collection install` already resolves `json` with `source: type: stdlib`) and json 3.0.2 -# ships no sig/ of its own, so the codec's one `JSON::Coder` reference is settled in the Steepfile's -# :serde_json target instead of here; net-http and async-http arrive with the transports in phase 8, +# in phase 0 because no gemspec declares a third-party dependency yet: json arrives with +# dexpace-serde-json's codec in phase 7, net-http and async-http with the transports in phase 8, # and each adds its own row here then. Core never gets one -- its allowlist is stdlib only, and # stdlib signatures ship with rbs itself. sources: From d4f7315789045a842d1fdefc37cc9c812ebe1a3c Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Sun, 20 Sep 2026 15:20:53 +0300 Subject: [PATCH 3/3] fix: name an anonymous witness in the two handler messages Review round 1, R1-4. DecodeContext.root gives an anonymous class no target (P7-70), so that `#error!` keeps its plain form and no `#` reaches a message -- but DecodingHandler#missing_body and StatusAwareHandler#unhandled_message interpolated that nil directly, and a 204 through a `Class.new` witness read "no body to decode into : the response carried none". Each handler now derives the name through one private #target_name that falls back to the literal "an anonymous witness", declared in the two sig/ mirrors. A named witness's messages are byte-for-byte what they were; P7-70's rule on the context is unchanged. --- .../lib/dexpace/serde/decoding_handler.rb | 11 +++++++++-- .../lib/dexpace/serde/status_aware_handler.rb | 10 ++++++++-- .../sig/dexpace/serde/decoding_handler.rbs | 1 + .../sig/dexpace/serde/status_aware_handler.rbs | 1 + 4 files changed, 19 insertions(+), 4 deletions(-) diff --git a/gems/dexpace-core/lib/dexpace/serde/decoding_handler.rb b/gems/dexpace-core/lib/dexpace/serde/decoding_handler.rb index 401339b..b3f377d 100644 --- a/gems/dexpace-core/lib/dexpace/serde/decoding_handler.rb +++ b/gems/dexpace-core/lib/dexpace/serde/decoding_handler.rb @@ -118,10 +118,17 @@ def decode(response) # SERDE-27's "naming the target type": the name DecodeContext.root derives for the witness. def missing_body DeserializationError.new( - "no body to decode into #{DecodeContext.root(target: witness).target}: the response " \ - "carried none (SERDE-27)", + "no body to decode into #{target_name}: the response carried none (SERDE-27)", ) end + + # The witness's name as DecodeContext.root derives it, with a literal for the one witness + # that has none: an anonymous class gets no target (P7-70, so `#error!` keeps its plain form + # and no `#` reaches a message), and interpolating that nil here would read + # "decode into : the response" (review round 1, R1-4). + def target_name + DecodeContext.root(target: witness).target || "an anonymous witness" + end end end end diff --git a/gems/dexpace-core/lib/dexpace/serde/status_aware_handler.rb b/gems/dexpace-core/lib/dexpace/serde/status_aware_handler.rb index cf33375..26ce79d 100644 --- a/gems/dexpace-core/lib/dexpace/serde/status_aware_handler.rb +++ b/gems/dexpace-core/lib/dexpace/serde/status_aware_handler.rb @@ -128,9 +128,15 @@ def unhandled_message(response) values = headers[name] "#{name}: #{values.join(", ")}" unless values.nil? || values.empty? end - target = DecodeContext.root(target: witness).target lead = "#{status.code} #{status.canonical_name}".rstrip - ["#{lead}: not decoded into #{target}, only a 2xx body is (SERDE-28)", *context].join("; ") + ["#{lead}: not decoded into #{target_name}, only a 2xx body is (SERDE-28)", *context] + .join("; ") + end + + # As DecodingHandler#target_name: the derived name, or a literal for an anonymous witness, + # which DecodeContext.root gives no target (P7-70; review round 1, R1-4). + def target_name + DecodeContext.root(target: witness).target || "an anonymous witness" end end end diff --git a/gems/dexpace-core/sig/dexpace/serde/decoding_handler.rbs b/gems/dexpace-core/sig/dexpace/serde/decoding_handler.rbs index 88e0042..5b67fae 100644 --- a/gems/dexpace-core/sig/dexpace/serde/decoding_handler.rbs +++ b/gems/dexpace-core/sig/dexpace/serde/decoding_handler.rbs @@ -18,6 +18,7 @@ module Dexpace def decode: (Dexpace::Response response) -> untyped def missing_body: () -> Dexpace::Serde::DeserializationError + def target_name: () -> String end end end diff --git a/gems/dexpace-core/sig/dexpace/serde/status_aware_handler.rbs b/gems/dexpace-core/sig/dexpace/serde/status_aware_handler.rbs index dc49df1..45b41b3 100644 --- a/gems/dexpace-core/sig/dexpace/serde/status_aware_handler.rbs +++ b/gems/dexpace-core/sig/dexpace/serde/status_aware_handler.rbs @@ -26,6 +26,7 @@ module Dexpace def raise_mapped: (Dexpace::Response response) -> bot def raise_unhandled: (Dexpace::Response response) -> bot def unhandled_message: (Dexpace::Response response) -> String + def target_name: () -> String end end end