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
23 changes: 23 additions & 0 deletions gems/dexpace-core/lib/dexpace.rb
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,29 @@
require_relative "dexpace/sse/stream"
require_relative "dexpace/sse/typed_stream"

# Phase 7c: the pagination layer, in dependency order -- after 6b's block, though it needs only
# phase 2's closeable.rb, phase 1's model and the async pivot. The page value first, because it is
# also the namespace every later file reopens; then the strategy vocabulary (the info value, the
# query splice, the private link-header grammar, the three built-in strategies), the private
# lifetime owner and the private close disciplines both views share, the two views, the two
# engines, and the fetcher front-end last. `uri` is required by url.rb, which page.rb requires; it
# was on the allowlist before phase 1, and nothing under page/ names a serializer.
require_relative "dexpace/page"
require_relative "dexpace/page/page_state_error"
require_relative "dexpace/page/info"
require_relative "dexpace/page/query_rewriter"
require_relative "dexpace/page/link_header"
require_relative "dexpace/page/cursor_strategy"
require_relative "dexpace/page/page_number_strategy"
require_relative "dexpace/page/link_strategy"
require_relative "dexpace/page/walk"
require_relative "dexpace/page/closing"
require_relative "dexpace/page/items"
require_relative "dexpace/page/pages"
require_relative "dexpace/page/paginator"
require_relative "dexpace/page/async_paginator"
require_relative "dexpace/page/fetchers"

# 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
Expand Down
37 changes: 37 additions & 0 deletions gems/dexpace-core/lib/dexpace/http/url.rb
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,43 @@ def external_form(uri)
uri.to_s
end

# PAGE-19: RFC 3986 reference resolution of `reference` against `base`, or nil when the
# reference cannot resolve into a URI at all -- PAGE-19 treats that as end-of-stream and not
# as an error, and phase 7c's Dexpace::Page.next_request_from is the caller that turns the nil
# into one. The third function of this module, added by phase 7c (P7-3) beside .parse! so
# the parser pin lives in one file: .parse! cannot resolve, because it rejects the relative
# reference every `<?page=2>; rel=next` is (HTTP-47), and URI.join is what the
# Dexpace/NoUriDefaultParser cop refuses (url-and-query-encoding/08c54234). This is reference
# resolution and never SEAM-27's base-URL composition, which Dexpace::Operation does by hand
# because the two disagree on a query-bearing base (P2-3).
#
# Two things it deliberately does not do. It does not screen the result for a scheme this
# client can dispatch: `join` hands back a `mailto:`, a host-less `http:foo` and an empty-host
# `http:///p` as SUCCESSFUL resolutions (6b's P6-95), and whether such a target is an
# end-of-stream or an error is the caller's decision, made once in Dexpace::Page. And it does
# not accept a nil reference as "nothing to resolve": `join(base, nil)` raises ArgumentError,
# not URI::InvalidURIError, so a nil here is a programming error named as such rather than a
# silent end-of-stream.
#
# @param base [URI::Generic, String] the originating page's absolute URL
# @param reference [String] the raw target, absolute or relative
# @return [URI::Generic, nil] the resolved URI, its components frozen; nil when malformed
# @raise [Dexpace::InvalidArgumentError] when either argument is missing or of the wrong type
def resolve(base, reference)
Model.required!("base", base)
unless base.is_a?(String) || base.is_a?(::URI::Generic)
raise InvalidArgumentError, "base must be a String or a URI, got #{base.class}"
end
unless Model.required!("reference", reference).is_a?(String)
raise InvalidArgumentError, "reference must be a String, got #{reference.class}"
end

parser = ::URI::RFC3986_PARSER #: untyped
own(parser.join(base.to_s, reference)) #: URI::Generic
rescue ::URI::InvalidURIError
nil
end

private

# Freezes the URI's String components, then the URI. Only Strings: a URI also references
Expand Down
167 changes: 167 additions & 0 deletions gems/dexpace-core/lib/dexpace/page.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
# frozen_string_literal: true
# SPDX-License-Identifier: MIT

require_relative "closeable"
require_relative "model"
require_relative "error/invalid_argument_error"
require_relative "http/request"
require_relative "http/response"
require_relative "http/url"

module Dexpace
# One page of a paginated walk (PAGE-2, PAGE-3), and the namespace of the whole pagination
# subsystem -- Info, the three built-in strategies, the two views, the two engines and the
# fetcher front-end all nest here (P7-2). Design §7.1 names exactly one identifier for the
# subsystem, `Dexpace::Page`, and appendix A calls the value "a Page"; nesting the rest inside
# it keeps `lib/dexpace/page/**` as one audited directory. None of the nested names shadows a
# Ruby core constant or a `Dexpace::` one (the smoke suite pins the list).
#
# A page owns exactly one live Dexpace::Response, and whoever holds the page owns closing it:
# the component that fetched the response hands ownership over the moment it builds the page
# (PAGE-3). The materialized items and the derived status, headers and originating request
# stay readable after #close, because they are read off a frozen Data that closing does not
# touch; only the body is invalidated (PAGE-2).
#
# A plain class including Dexpace::Closeable, deliberately NOT a Data. A Data instance is
# frozen and cannot hold the latch (phase 3b's finding, applied one layer up), and the latch
# cannot be borrowed from the response either: Response#close is a pure forward to the body,
# and the body slot may hold nil, a BufferBody whose #close is a documented no-op, or -- only
# in the transport case -- a latching ResponseBody. So PAGE-27's exactly-once close, on every
# path that consumes a page, is a property of the page itself: the latch is here, #release
# forwards to the response once, and a second #close returns without touching it.
class Page
include Dexpace::Closeable

private_class_method :new

# The validating factory every page goes through: a real Dexpace::Response (checked the way
# Response#initialize checks its request), an Array of items, copied shallowly and frozen
# -- the collection is the model's, its elements stay the caller's (HTTP-5, XCUT-15; a deep
# copy through Model.own would replace and freeze domain objects and raise on a Proc, P7-101)
# -- and the two PAGE-34 keys, each nil or a String copied frozen, exactly as Info holds them:
# a key of another class is named and refused here rather than discovered by the fetcher
# front-end as a NoMethodError when it keys the next page off it (P7-107).
#
# @param response [Dexpace::Response] the live response this page owns from now on
# @param items [Array] the materialized items, possibly empty, never nil (PAGE-2)
# @param next_link [String, nil] PAGE-34's next link, when the source carried one
# @param continuation_token [String, nil] PAGE-34's fallback key
# @return [Dexpace::Page]
# @raise [Dexpace::InvalidArgumentError] on a missing response, a non-Array items, or a link
# or token that is neither nil nor a String
def self.build(response:, items:, next_link: nil, continuation_token: nil)
unless Model.required!("response", response).is_a?(Dexpace::Response)
raise InvalidArgumentError, "response must be a Dexpace::Response, got #{response.class}"
end
unless Model.required!("items", items).is_a?(::Array)
raise InvalidArgumentError, "items must be an Array, got #{items.class}"
end

new(response, items, next_link, continuation_token)
end

# PAGE-19's whole answer, shipped once: the next request for a rel=next target -- or any
# URL a strategy found in a header, a body or elsewhere -- resolved against the originating
# page's response URL, or nil for end-of-stream. Public rather than private to LinkStrategy
# because a next-page URL in the response BODY is the one shape a generated client commonly
# needs that none of the three built-ins covers, and a caller-written #parse that re-derives
# this branch by hand gets the two end-of-stream rules quietly wrong (P7-2).
#
# Four routes to nil, in order. A nil target, or a same-document reference -- RFC 3986 §4.4's
# two named forms, the empty reference and the fragment-only one, `<>` and `<#…>`, surrounding
# whitespace aside -- is end-of-stream BEFORE resolution is attempted: `URI::RFC3986_PARSER
# .join(base, "")` succeeds and answers the base itself, `join(base, "#x")` the base plus a
# fragment the wire never carries, so either `rel=next` would otherwise re-fetch the current
# page until the page cap (P7-5, widened to the fragment form by P7-117). A target that cannot
# resolve at all is end-of-stream, never an error (PAGE-19's own text). A target that resolves
# to something this client cannot dispatch -- no http or https scheme, or no host: `mailto:`,
# `javascript:`, `http:foo`, `http:///p`, every one a SUCCESSFUL join -- is end-of-stream too,
# consistent with REDIR-18's screen in the redirect step (P7-104). Otherwise
# `template.with(url:)`, which is PAGE-23's "swap only the URL, preserving the template's
# method, headers and body": Model#with routes through Request.build, so the other three
# members travel unchanged and HTTP-7 is re-validated. What is deliberately NOT screened is
# the URL a target resolves to: `<?>`, which empties the query, `<//>`, which uri resolves to
# the base itself, or the current URL spelled out are next requests like any other, and a
# server that loops through one is bounded by PAGE-9's cap -- a screen on the resolved URL
# would silently end a walk against an endpoint that advances server-side state under one
# URL, with no knob to turn it off.
#
# @param template [Dexpace::Request] the original request template
# @param response [Dexpace::Response] the page whose URL is the resolution base
# @param target [String, nil] the raw target, absolute or relative
# @return [Dexpace::Request, nil] nil is the end-of-stream signal (PAGE-4)
def self.next_request_from(template, response, target)
return nil if target.nil? || same_document?(target)

resolved = Dexpace::URL.resolve(response.request.url, target)
return nil if resolved.nil? || !dispatchable?(resolved)

template.with(url: resolved)
end

# The schemes this client dispatches; the same two the redirect step screens for.
DISPATCHABLE_SCHEMES = %w[http https].freeze
private_constant :DISPATCHABLE_SCHEMES

# RFC 3986 §4.4's two named same-document forms, read off the raw target before any
# resolution: empty (P7-5) or fragment-only (P7-117), surrounding whitespace ignored. Syntactic
# on purpose -- a browser reloads on a spelled-out link to the current page and only an empty
# or fragment-only one navigates nowhere -- so this reads the reference, never its resolution.
def self.same_document?(target)
stripped = target.strip
stripped.empty? || stripped.start_with?("#")
end
private_class_method :same_document?

# 6b's screen, copied rather than called: Redirect::Location is a private_constant.
def self.dispatchable?(target)
scheme = target.scheme
host = target.host
!scheme.nil? && DISPATCHABLE_SCHEMES.include?(scheme.downcase) && !host.nil? && !host.empty?
end
private_class_method :dispatchable?

def initialize(response, items, next_link, continuation_token)
@response = response
@items = items.dup.freeze
@next_link = optional_string("next_link", next_link)
@continuation_token = optional_string("continuation_token", continuation_token)
initialize_closeable(owned: true)
end

# @return [Dexpace::Response] the live response; its body is invalid once the page is closed
attr_reader :response
# @return [Array] the frozen materialized items, readable after close (PAGE-2)
attr_reader :items
# @return [String, nil] PAGE-34's next link, as the source supplied it
attr_reader :next_link
# @return [String, nil] PAGE-34's continuation token, the fallback key
attr_reader :continuation_token

# @return [Dexpace::Status] the response's status, readable after close (PAGE-2)
def status = @response.status

# @return [Dexpace::Headers] the response's headers, readable after close (PAGE-2)
def headers = @response.headers

# @return [Dexpace::Request] the originating (executed) request, readable after close (PAGE-2)
def request = @response.request

private

# nil, or a frozen copy of the String (XCUT-15); anything else is named and refused -- the same
# rule Info applies to the same two members, so a key travels from an Info to a Page unchanged.
def optional_string(name, value)
return nil if value.nil?
return Model.frozen_string(value) if value.is_a?(::String)

raise InvalidArgumentError, "#{name} must be a String or nil, got #{value.class}"
end

# Closeable's one obligation: forward to the response exactly once. A raising close leaves the
# latch flipped, so no second release is attempted and the failure propagates once (PAGE-3).
def release
@response.close
end
end
end
Loading
Loading