Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,14 @@ Style/StringLiteralsInInterpolation:
Layout/LineLength:
Max: 100

# The Steepfile's `core` target is strict, and strict Steep refuses an empty collection literal
# with no type annotation (Ruby::UnannotatedEmptyCollection); the annotation is Steep's own
# `#: Type` trailing comment, which this cop's default rejects for lacking a space after the
# hash. Every ordinary comment is still held to the space rule; only the annotation form passes
# (phase 1, at the first model that accumulates into a `[]`).
Layout/LeadingCommentSpace:
AllowRBSInlineAnnotation: true

# styleguide 01 rule 1.10 -- trailing comma mandatory, chains break with a leading dot.
Style/TrailingCommaInArrayLiteral:
EnforcedStyleForMultiline: consistent_comma
Expand Down
33 changes: 32 additions & 1 deletion gems/dexpace-core/lib/dexpace.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,44 @@
# SPDX-License-Identifier: MIT

require_relative "dexpace/version"
require_relative "dexpace/error"
require_relative "dexpace/error/invalid_argument_error"
require_relative "dexpace/model"
require_relative "dexpace/builder"
require_relative "dexpace/http/header_syntax"
require_relative "dexpace/http/header_name"
require_relative "dexpace/http/headers"
require_relative "dexpace/http/headers/builder"
require_relative "dexpace/http/status"
require_relative "dexpace/http/method"
require_relative "dexpace/http/protocol"
require_relative "dexpace/http/media_type"
require_relative "dexpace/http/percent_encoding"
require_relative "dexpace/http/query"
require_relative "dexpace/http/query/builder"
require_relative "dexpace/http/url"
require_relative "dexpace/http/request_options"
require_relative "dexpace/http/request_options/builder"
require_relative "dexpace/http/request"
require_relative "dexpace/http/request/builder"
require_relative "dexpace/http/response"
require_relative "dexpace/http/response/builder"

# 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
# autoloader. That is not stylistic: every Ruby autoloader worth using is a gem, and SEAM-1 bars
# core from depending on one (docs/knowledge/notes/module-organization.md). It also turns the
# require-graph audit into a text scan rather than a runtime trace. Adding a file under
# lib/dexpace/ means adding a line above.
# lib/dexpace/ means adding a line above -- in dependency order, not alphabetical: a builder file
# follows its model because the model's EMPTY-style constants are evaluated at require time.
#
# The public wire model is flat under this namespace (Dexpace::Request, Dexpace::Headers) while
# its files sit under lib/dexpace/http/; the directory organises files and is not a namespace
# segment (docs/knowledge/notes/module-organization.md, phase 1's P1-1). Two of the constants
# shadow a Ruby core name inside `module Dexpace`: Dexpace::Method (::Method) is the one this
# phase adds. Write `::Method` inside core when Ruby's is meant; outside core a consumer's
# top-level Method still resolves to Ruby's, even after `include Dexpace`, because Object's own
# constants win over an included module's.
module Dexpace
end
32 changes: 32 additions & 0 deletions gems/dexpace-core/lib/dexpace/builder.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# frozen_string_literal: true
# SPDX-License-Identifier: MIT

require_relative "error/invalid_argument_error"

module Dexpace
# SEAM-29's shared generic builder contract: `#build` produces the target type, and a generic
# composition helper can therefore accept any builder without knowing which model it makes.
#
# In Ruby the assignability half of that requirement is carried by the RBS interface
# `Dexpace::_Builder[T]`, which is what a consumer's own `steep check` sees; this module is its
# runtime half, so a helper can also refuse an object that never opted in.
module Builder
# The generic composition helper the requirement exists for.
def self.build_all(builders)
builders.map do |builder|
unless builder.is_a?(Builder)
raise InvalidArgumentError, "#{builder.class} does not implement the builder contract"
end

builder.build
end
end

# Deliberately NotImplementedError, which is a ScriptError and not a StandardError: a builder
# class that forgot #build is a programmer error and must not be swallowed by an ordinary
# `rescue`.
def build
raise NotImplementedError, "#{self.class} must implement #build (SEAM-29)"
end
end
end
19 changes: 19 additions & 0 deletions gems/dexpace-core/lib/dexpace/error.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# frozen_string_literal: true
# SPDX-License-Identifier: MIT

module Dexpace
# The marker every error raised by this SDK carries.
#
# A module rather than a base class, and the choice is forced: XCUT-4 requires a transport error
# to belong to the runtime's I/O-error family so existing I/O rescue sites keep matching, which
# in Ruby means `Dexpace::TransportError < ::IOError`. Ruby has single inheritance, so a class
# root would make that requirement unsatisfiable in phase 8. `rescue` matches with Module#===,
# which is `is_a?`, so `rescue Dexpace::Error` catches every including class exactly as a base
# class would.
#
# Design §5's suppressed-exception trail -- #suppressed, the #detailed_message override and
# Dexpace.attach_suppressed -- lands in phase 4 with the recovery chain that is its first
# caller (phase 4b, Task 1). It is deliberately absent rather than stubbed here.
module Error
end
end
27 changes: 27 additions & 0 deletions gems/dexpace-core/lib/dexpace/error/invalid_argument_error.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# frozen_string_literal: true
# SPDX-License-Identifier: MIT

require_relative "../error"

module Dexpace
# Raised when a caller supplies an argument no model can accept: a missing required field
# (HTTP-4, in SEAM-29's "<name> is required" form), a malformed URL (HTTP-47), a header name
# carrying a control byte (HTTP-17).
#
# It subclasses Ruby's own ArgumentError because that is what the failure IS -- a caller
# mistake, which the styleguide raises from validation helpers and never rescues in ordinary
# flow. The SDK-specific name is InvalidArgumentError, never Dexpace::ArgumentError: the latter
# would shadow ::ArgumentError for every file inside `module Dexpace`, so a bare
# `rescue ArgumentError` in core would silently stop catching Ruby's.
#
# The rule for when core converts a stdlib exception into this one, fixed here because every
# later factory decides it again otherwise: a public factory or coercion that raises a stdlib
# exception BECAUSE OF the argument it was given rescues it and re-raises this class naming the
# argument, in SEAM-29's message form, with the original left as the `cause` (URL.parse! is the
# phase-1 example). A factory that can pre-empt the stdlib exception -- a byte check before a
# case fold, an Integer check before a range test -- does that instead and adds no rescue
# (Status.of, Method.of, Protocol.parse).
class InvalidArgumentError < ::ArgumentError
include Dexpace::Error
end
end
76 changes: 76 additions & 0 deletions gems/dexpace-core/lib/dexpace/http/header_name.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# frozen_string_literal: true
# SPDX-License-Identifier: MIT

require_relative "../model"
require_relative "header_syntax"

module Dexpace
# A header name that compares by its case-folded form and emits its original casing (HTTP-21).
#
# ONE member, the original casing. The fold is derived from it at construction and exposed as
# #folded, for the reason Status keeps its canonical name out of its members: a derived value
# that is also a member can be handed in stale -- `#with(original: "Content-Type")` carries the
# old fold in `to_h` -- and would have to be accepted and ignored. Setting it before `super`
# is the one moment a Data instance is still mutable, and it is what makes Model#with's route
# through `.build` load-bearing on Ruby 3.2, where the inherited Data#with never runs this
# constructor and would leave the fold unset.
#
# HTTP-22's process-wide interning is a MAY and is not built: the observable contract is value
# equality by folded name, which this type gives without it.
class HeaderName < Data.define(:original)
include Model

private_class_method :new

# The case-folded name: HTTP-13's key for lookup, containment, equality and hashing.
attr_reader :folded

# The validating factory every construction path goes through.
def self.build(original:)
new(original: original)
end

# A String or a HeaderName, so every string-keyed Headers method interoperates with the typed
# one at no cost when the caller already has the type.
def self.of(name)
return name if name.is_a?(HeaderName)

build(original: name)
end

# The validating constructor. HTTP-13's fold is `downcase` with NO argument, ever: Ruby's fold
# is opt-in-locale -- "I".downcase(:turkic) is "ı" -- and the repository-wide cop rejects the
# argument form, so the ASCII/invariant guarantee is enforced rather than assumed. The fold
# never meets a non-ASCII byte anyway, because validate_name! rejected one first; relaxing
# HTTP-17 would therefore break HTTP-13, which is why that dependency is stated here. Nor
# does it meet a tag Ruby cannot fold under: validate_name! hands back the proven bytes under
# an ASCII-compatible tag, so a stateful-encoding name folds instead of crashing.
def initialize(original:)
Model.required!("header name", original)
raise InvalidArgumentError, "header name must be a String" unless original.is_a?(String)

trimmed = HeaderSyntax.validate_name!(original)
@folded = trimmed.downcase.freeze
super(original: Model.frozen_string(trimmed))
end

# The original casing, for wire emission.
def to_s
original
end

# Data generates equality over the original casing, which would make "Accept" and "accept"
# unequal and violate HTTP-21. Folded-only comparison is the observable contract; the
# original casing is carried for emission and takes no part in it (api-design/e4fa3438
# permits the override with this comment).
def ==(other)
other.is_a?(HeaderName) && folded == other.folded
end
alias eql? ==

# Agrees with #==: two casings of one name share a Hash slot.
def hash
folded.hash
end
end
end
154 changes: 154 additions & 0 deletions gems/dexpace-core/lib/dexpace/http/header_syntax.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
# frozen_string_literal: true
# SPDX-License-Identifier: MIT

require_relative "../error/invalid_argument_error"

module Dexpace
# The header grammar, as bytes.
#
# Public API on purpose: HTTP-17/HTTP-18/XCUT-18 are re-checked at the model-to-wire boundary
# inside every transport adapter (phase 8a Task 16, phase 8c Task 9), and an adapter lives in a
# different gem, so it must be able to reach the predicate without building a model. That
# re-check is what makes the encapsulation gap of design §10.10 a correctness-of-shape gap
# rather than a request-splitting one.
#
# Every predicate reads `value.b.each_byte`, never a character regexp: a String carrying
# invalid UTF-8 is exactly the input this module exists to reject, and a character operation
# on one raises ArgumentError or Encoding::CompatibilityError from inside Ruby instead of
# returning false. A validator that crashes has not rejected its input. The converse hazard is
# a String whose bytes pass and whose TAG Ruby refuses to fold under; #ascii_compatible is the
# one place that is normalised, for every factory that folds or scans after a byte check.
module HeaderSyntax
extend self

# Horizontal tab: the one control byte a field value may carry (HTTP-18, HTTP-19).
HTAB = 0x09
# The space byte: trimmed from around a name (HTTP-17), never permitted inside one.
SPACE = 0x20
# DEL: rejected in every position by both grammars.
DEL = 0x7F
# The two bytes #trim removes from around a name, and the only two. An implementation detail
# of #trim rather than part of the grammar a transport re-checks, so it is kept out of the
# public surface NFR-4 locks at the first release tag.
TRIMMABLE = [SPACE, HTAB].freeze
private_constant :TRIMMABLE
# RFC 7230 tchar, as BYTES: the grammar of a method token, a media-type component and a
# bare parameter value. tchar is ASCII-only, so a byte test is exact as well as total.
TCHAR = [
*"0".."9", *"A".."Z", *"a".."z",
"!", "#", "$", "%", "&", "'", "*", "+", "-", ".", "^", "_", "`", "|", "~",
].map(&:ord).freeze

# HTTP-17's "surrounding whitespace is trimmed before validation" -- SP and HTAB only.
#
# Not String#strip, twice over. It also strips NUL, so "a\0" would trim to "a" and pass the
# check that exists to reject NUL; and it raises Encoding::CompatibilityError on a name
# carrying invalid UTF-8, which is an input this function must REJECT rather than crash on.
# Trimming CR or LF would be worse still: "a\r\n" would become a valid name.
#
# The result is the surviving BYTES, tagged BINARY: this function is total over any String,
# including one whose bytes are invalid in its own encoding, so it cannot promise a tag.
def trim(name)
bytes = name.b
start = 0
finish = bytes.bytesize
start += 1 while start < finish && trimmable?(bytes.getbyte(start))
finish -= 1 while finish > start && trimmable?(bytes.getbyte(finish - 1))
# byteslice is typed String? for a start past the end; start never is, so the alternative
# is unreachable and only keeps the declared return type honest.
bytes.byteslice(start, finish - start) || ""
end

# HTTP-17: no C0 control, no DEL, no byte >= 0x80, not blank. Every byte below 0x21 is
# rejected, which covers the controls, SP and HTAB -- a header name has no interior space.
def valid_name?(name)
trimmed = trim(name)
return false if trimmed.empty?

trimmed.each_byte.none? { |byte| byte < 0x21 || byte == DEL || byte >= 0x80 }
end

# HTTP-18: HTAB plus printable ASCII 0x20-0x7E, and nothing else.
def valid_outbound_value?(value)
value.b.each_byte.all? { |byte| byte == HTAB || (byte >= SPACE && byte < DEL) }
end

# HTTP-19: the same rule with the non-ASCII clause relaxed, because RFC 7230 permits obs-text
# in a field value and applying the outbound grammar to a response would silently drop a
# legitimate Latin-1 Content-Disposition.
def valid_inbound_value?(value)
value.b.each_byte.all? { |byte| byte == HTAB || (byte >= SPACE && byte != DEL) }
end

# The trimmed name when it is valid; otherwise HTTP-20's escaped echo of it in the message.
#
# A valid name is printable ASCII, so the trimmed bytes are retagged with the caller's own
# encoding when that encoding can carry ASCII, and US-ASCII when it cannot -- the tag
# #ascii_compatible gives the caller's String. `force_encoding` is a retag used only where
# the bytes are known to conform, and here they are proven to.
def validate_name!(name)
return trim(name).force_encoding(ascii_compatible(name).encoding) if valid_name?(name)

raise InvalidArgumentError,
"header name #{escape(name)} is not a valid field name (HTTP-17)"
end

# The same bytes under a tag every character operation accepts.
#
# The byte predicates above are total, so a String whose tag cannot carry ASCII -- a
# stateful encoding such as ISO-2022-JP, a wide one such as UTF-16 -- passes them with the
# same bytes as the literal it was encoded from, and the fold, upcase or scan that follows
# then raises Encoding::CompatibilityError from inside Ruby, escaping `rescue Dexpace::Error`
# exactly as a regexp over invalid UTF-8 would. Every ASCII-compatible encoding spells ASCII
# identically, so such a String is returned as it is and costs nothing; otherwise a copy of
# the bytes is tagged US-ASCII when they are ASCII, and left BINARY when they are not, which
# is a legal label either way. The caller's own String is never retagged in place.
def ascii_compatible(text)
return text if text.encoding.ascii_compatible?

bytes = text.b
bytes.ascii_only? ? bytes.force_encoding(Encoding::US_ASCII) : bytes
end

# The value unchanged when the outbound grammar accepts it. The message names the header and
# never the value (HTTP-20).
def validate_outbound_value!(value, name:)
return value if valid_outbound_value?(value)

raise InvalidArgumentError,
"value for header #{escape(name)} contains a byte no outbound header value may " \
"carry (HTTP-18)"
end

# The value unchanged when the inbound grammar accepts it; the same no-echo rule applies.
def validate_inbound_value!(value, name:)
return value if valid_inbound_value?(value)

raise InvalidArgumentError,
"value for header #{escape(name)} contains a control byte (HTTP-19)"
end

# RFC 7230 token: one or more tchar. Not a regexp: `Regexp#match?` raises ArgumentError on a
# String carrying invalid UTF-8, and an ArgumentError raised from inside Ruby escapes
# `rescue Dexpace::Error` -- a validator that crashes has not rejected its input.
def token?(text)
bytes = text.b
!bytes.empty? && bytes.each_byte.all? { |byte| TCHAR.include?(byte) }
end

# HTTP-20: a rejected VALUE never appears in a message in any form, and a name is echoed with
# its control bytes escaped, so a rejected header cannot inject a line into a log.
def escape(name)
escaped = name.to_s.b.each_byte.map do |byte|
byte < SPACE || byte == DEL || byte >= 0x80 ? format("\\x%02X", byte) : byte.chr
end
escaped.join
end

private

def trimmable?(byte)
TRIMMABLE.include?(byte)
end
end
end
Loading
Loading