class Gems::Resource
Base class for objects that wrap RubyGems API responses
The endpoints answer with the resources the library defines, which declare their readers with {.attribute}, {.predicate}, and {.time_attribute}, and declare what they inspect with and what identifies them with {.inspect_with} and {.identified_by}. Those declarations are private, since no endpoint answers with a resource of your own, so they can change within 3.x; the readers they declare, {.attribute_names}, and the rest of the instance interface are public.
@api public
Attributes
The raw attributes from the API response @api public @return [Hash{String => Object}] the raw attributes @example Get the raw attributes
gem.attributes["name"]
Public Class Methods
Define a reader for an attribute
Endpoints spell some fields differently, so a reader may declare several keys and reads the first one the response contains.
@api private @param name [Symbol] the name of the reader @param keys [Array<String, Symbol>] the attribute keys, most preferred first (defaults to the name) @return [Symbol] the name of the reader @example Declare a reader
attribute :name
# File lib/gems/resource.rb, line 49 def self.attribute(name, *keys) keys = keys_for(name, keys) define_method(name) do # @type self: Resource value_of(keys) end record_attribute(name) end
The names of the readers the class declares
These are the readers declared with attribute, predicate, and time_attribute, and a pattern matches a resource by these names (see {#deconstruct_keys}).
A subclass of a resource inherits the readers its superclass declared, until it declares readers of its own.
@api public @return [Array<Symbol>] the reader names, in the order they were declared @example
Gems::Owner.attribute_names # => [:id, :handle, :email, :role]
# File lib/gems/resource.rb, line 106 def self.attribute_names @attribute_names || (superclass.attribute_names if superclass.respond_to?(:attribute_names)) || [] # steep:ignore NoMethod end
Declare which readers identify the resource
Resources with an identity compare equal when those readers match, even if other attributes differ.
@api private @param readers [Array<Symbol>] the identifying readers @return [Array<Symbol>] the identifying readers @example Declare what identifies a resource
identified_by :name, :number, :platform, :ruby_abi
# File lib/gems/resource.rb, line 162 def self.identified_by(*readers) @identity_readers = readers end
The readers that identify the resource
@api private @return [Array<Symbol>] the identifying readers, inherited from the superclass until the class declares its
own, and empty when the resource is identified by all of its attributes
@example
Gems::Gem.identity_readers # => [:name, :version, :platform, :ruby_abi]
# File lib/gems/resource.rb, line 173 def self.identity_readers @identity_readers || (superclass.identity_readers if superclass.respond_to?(:identity_readers)) || [] # steep:ignore NoMethod end
The readers shown in the inspect output
@api private @return [Array<Symbol>] the readers to show, inherited from the superclass until the class declares its own @example
Gems::Gem.inspect_readers # => [:name, :version]
# File lib/gems/resource.rb, line 149 def self.inspect_readers @inspect_readers || (superclass.inspect_readers if superclass.respond_to?(:inspect_readers)) || [] # steep:ignore NoMethod end
Declare which readers appear in the inspect output
@api private @param readers [Array<Symbol>] the readers to show @return [Array<Symbol>] the readers to show @example Declare the readers a resource inspects with
inspect_with :name, :number
# File lib/gems/resource.rb, line 139 def self.inspect_with(*readers) @inspect_readers = readers end
Build a list of resources from a list of attribute hashes
@api public @param list [Array<Hash{String, Symbol => Object}>] the attribute hashes @return [Array<Resource>] the resources @example Build a list of gems
Gems::Gem.list(JSON.parse(body))
# File lib/gems/resource.rb, line 34 def self.list(list) list.map { |attributes| new(attributes) } #: Array[instance] end
Initialize a new resource
The attributes are deeply copied and frozen, so resources are immutable values and the hash passed in stays mutable. Symbol keys are converted to strings, at every level.
@api public @param attributes [Hash{String, Symbol => Object}] the raw attributes from the API response @return [Resource] a new instance @example Wrap a parsed response
Gems::Gem.new(JSON.parse(body))
# File lib/gems/resource.rb, line 187 def initialize(attributes) @attributes = deep_freeze(attributes) end
Define a predicate for a boolean attribute
@api private @param name [Symbol] the name of the attribute (the reader is suffixed with a question mark) @param keys [Array<String, Symbol>] the attribute keys, most preferred first (defaults to the name) @return [Symbol] the name of the reader @example Declare a predicate for a boolean field
predicate :yanked
# File lib/gems/resource.rb, line 66 def self.predicate(name, *keys) keys = keys_for(name, keys) define_method(:"#{name}?") do # @type self: Resource !!value_of(keys) end record_attribute(:"#{name}?") end
Define a reader that parses a timestamp attribute
The reader raises {InvalidResponse} when the attribute is not a timestamp.
@api private @param name [Symbol] the name of the reader @param keys [Array<String, Symbol>] the attribute keys, most preferred first (defaults to the name) @return [Symbol] the name of the reader @example Declare a reader that parses a timestamp field
time_attribute :created_at
# File lib/gems/resource.rb, line 85 def self.time_attribute(name, *keys) keys = keys_for(name, keys) define_method(name) do # @type self: Resource value = value_of(keys) value && parse_time(value) end record_attribute(name) end
Public Instance Methods
Compare with another resource
@api public @param other [Object] the object to compare with @return [Boolean] true if the other object is the same kind of resource with the same identity @example Compare two gems
Gems.rubygem("rails") == Gems.rubygem("rails") # => true
# File lib/gems/resource.rb, line 257 def ==(other) other.instance_of?(self.class) && identity == other.identity end
Read a raw attribute
@api public @param key [String, Symbol] the attribute key @return [Object, nil] the attribute value @example Read an attribute that has no reader
gem[:dependencies]
# File lib/gems/resource.rb, line 198 def [](key) attributes[key.to_s] end
The attributes a pattern asks for, read by the declared readers
A resource matches a ‘case`/`in` pattern by the names in {.attribute_names}, read as the readers read them, so a pattern sees a timestamp as a `Time` and a boolean as a predicate such as `yanked?`.
A name whose reader raises {InvalidResponse}, such as {APIKey#key} for a response that carries no key or a timestamp that cannot be parsed, is left out rather than raised from: a pattern asking for it does not match, and one asking for the rest of the attributes, such as ‘in {name:, **rest}`, matches without it. Reading that reader still raises, so a caller that asks for the attribute is told why it cannot be read.
@api public @param keys [Array<Symbol>, nil] the names the pattern asks for, or nil for all of them @return [Hash{Symbol => Object}] the requested attributes, without the ones that cannot be read @example Match a gem by its name and version
case Gems.rubygem("rails") in {name: "rails", version:} then version end
# File lib/gems/resource.rb, line 238 def deconstruct_keys(keys) names = self.class.attribute_names names &= keys unless keys.nil? requested = {} #: Hash[Symbol, untyped] names.each do |name| requested[name] = public_send(name) rescue InvalidResponse # The attribute cannot be read, so the pattern is answered without it end requested end
Compare with another resource for use as a hash key
Unlike {#==}, identity values are compared with ‘eql?`, so this agrees with {#hash}.
@api public @param other [Object] the object to compare with @return [Boolean] true if the other object is the same kind of resource with an eql? identity @example Compare two gems strictly
Gems.rubygem("rails").eql?(Gems.rubygem("rails")) # => true
# File lib/gems/resource.rb, line 270 def eql?(other) other.instance_of?(self.class) && identity.eql?(other.identity) end
Generate a hash code for the resource
@api public @return [Integer] the hash code @example Use resources as hash keys
{gem => true}
# File lib/gems/resource.rb, line 304 def hash [self.class, identity].hash end
The values that identify the resource
A resource answers with these to the resource it is compared with, rather than to a caller, which reads the readers {.identified_by} names or the attributes themselves: the values are what the comparison is made of, and are an Array of readers for one resource and the whole of the attributes for another.
@api private @return [Array<Object>, Hash{String => Object}] the identifying values, or all attributes when no identity is
declared
@example Compare two gems by the name, version, and platform that identify them
Gems.rubygem("rails") == Gems.rubygem("rails") # => true
# File lib/gems/resource.rb, line 321 def identity readers = self.class.identity_readers readers.empty? ? attributes : readers.map { |reader| public_send(reader) } end
Summarize the resource for the console
Only the readers declared with {.inspect_with} are shown, so the output stays short and never includes secrets.
@api public @return [String] the summary @example Inspect a gem
gem.inspect # => #<Gems::Gem name="rails" version="8.1.3.1">
# File lib/gems/resource.rb, line 282 def inspect fields = inspect_values.map { |reader, value| " #{reader}=#{value.inspect}" } "#<#{self.class}#{fields.join}>" end
The readers {#inspect} shows, with their values
These are the readers {.inspect_with} declares, which a resource that shows more when it has it adds to.
@api private @return [Hash{Symbol => Object}] the value of each reader shown, in the order they are shown
# File lib/gems/resource.rb, line 334 def inspect_values = self.class.inspect_readers.to_h { |reader| [reader, public_send(reader)] } # The value of the first of the given keys the response contains # @api private # @param keys [Array<String>] the attribute keys, most preferred first # @return [Object, nil] the value, or nil when the response contains none of the keys def value_of(keys) keys.each { |key| return self[key] if attributes.key?(key) } nil end # Parse a timestamp attribute # @api private # @param value [Object] the attribute value # @return [Time] the parsed time # @raise [InvalidResponse] if the value is not a timestamp def parse_time(value) Time.parse(value) rescue ArgumentError, TypeError raise InvalidResponse.new(body: value.to_s, message: "#{value.inspect} is not a timestamp") end end
Parse a timestamp attribute @api private @param value [Object] the attribute value @return [Time] the parsed time @raise [InvalidResponse] if the value is not a timestamp
# File lib/gems/resource.rb, line 350 def parse_time(value) Time.parse(value) rescue ArgumentError, TypeError raise InvalidResponse.new(body: value.to_s, message: "#{value.inspect} is not a timestamp") end
Convert the resource to a hash
The hash is a copy the caller owns and may change, as the hash ‘to_h` answers with elsewhere in Ruby is, and so is everything nested in it: the hashes, arrays, and strings a resource holds are frozen, so they are copied rather than handed to the caller to raise `FrozenError` on. {#attributes} answers with the frozen hash itself, for reading it without the copy.
@api public @return [Hash{String => Object}] a copy of the raw attributes @example Convert a gem to a hash
gem.to_h
@example Add a field of your own to the copy
gem.to_h.merge!("fetched_at" => Time.now)
@example Change a field nested in the copy
gem.to_h["metadata"]["fetched_at"] = Time.now
# File lib/gems/resource.rb, line 217 def to_h deep_dup(attributes) #: Hash[String, untyped] end
Summarize the resource as a String
The summary {#inspect} answers with, so that a resource written into a message or a log reads as the one the console shows rather than as the address the object sits at.
@api public @return [String] the summary @example Write a gem into a message
"fetched #{gem}" # => 'fetched #<Gems::Gem name="rails" version="8.1.3.1">'
# File lib/gems/resource.rb, line 296 def to_s = inspect # Generate a hash code for the resource # # @api public # @return [Integer] the hash code # @example Use resources as hash keys # {gem => true} def hash [self.class, identity].hash end protected # The values that identify the resource # # A resource answers with these to the resource it is compared with, rather than to a caller, which reads the # readers {.identified_by} names or the attributes themselves: the values are what the comparison is made of, # and are an Array of readers for one resource and the whole of the attributes for another. # # @api private # @return [Array<Object>, Hash{String => Object}] the identifying values, or all attributes when no identity is # declared # @example Compare two gems by the name, version, and platform that identify them # Gems.rubygem("rails") == Gems.rubygem("rails") # => true def identity readers = self.class.identity_readers readers.empty? ? attributes : readers.map { |reader| public_send(reader) } end private # The readers {#inspect} shows, with their values # # These are the readers {.inspect_with} declares, which a resource that shows more when it has it adds to. # # @api private # @return [Hash{Symbol => Object}] the value of each reader shown, in the order they are shown def inspect_values = self.class.inspect_readers.to_h { |reader| [reader, public_send(reader)] } # The value of the first of the given keys the response contains # @api private # @param keys [Array<String>] the attribute keys, most preferred first # @return [Object, nil] the value, or nil when the response contains none of the keys def value_of(keys) keys.each { |key| return self[key] if attributes.key?(key) } nil end # Parse a timestamp attribute # @api private # @param value [Object] the attribute value # @return [Time] the parsed time # @raise [InvalidResponse] if the value is not a timestamp def parse_time(value) Time.parse(value) rescue ArgumentError, TypeError raise InvalidResponse.new(body: value.to_s, message: "#{value.inspect} is not a timestamp") end end end
The value of the first of the given keys the response contains @api private @param keys [Array<String>] the attribute keys, most preferred first @return [Object, nil] the value, or nil when the response contains none of the keys
# File lib/gems/resource.rb, line 340 def value_of(keys) keys.each { |key| return self[key] if attributes.key?(key) } nil end