Class: ActiveSanction::Validators

Inherits:
Object
  • Object
show all
Extended by:
T::Sig
Defined in:
lib/active_sanction/validators.rb

Overview

The cache validators a publisher handed back with a list, plus enough context to know whether they still apply.

validators.request_headers
# => { "If-None-Match" => "\"0953154d0fb5aff918c5ec1daf6e9c0e\"",
#      "If-Modified-Since" => "Fri, 28 Aug 2026 14:02:55 GMT" }

Every launch source serves both, verified live -- OFAC's SDN.CSV, the UN's consolidated.xml and Canada's sema-lmes.xml -- so a routine sync of lists that change daily at most costs three 304s instead of tens of megabytes.

Both values are stored and echoed back verbatim. An ETag is an opaque string that only its origin server can interpret, and Last-Modified is an HTTP-date whose formatting is the server's business; parsing either one to re-render it is how a working conditional GET turns into a full download nobody notices.

url is the URL that was requested, not the one that finally answered: OFAC redirects to blob storage, and the hop it lands on is not stable enough to key a cache by. It is kept so validators can be discarded when a publisher moves its file -- sending a previous file's ETag to a new URL invites a 304 that means nothing.

Instances are frozen on construction and compare by value.

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(url:, etag: nil, last_modified: nil, checked_at: nil, updated_at: nil) ⇒ void

checked_at is when the publisher last confirmed this copy, whether by 200 or by 304; updated_at is when the bytes themselves last changed. Staleness follows the first, because "when did we last ask?" is the question a sync schedule answers; the second is what a human wants when reading why a list looks old.

Parameters:

  • url (T.untyped)
  • etag (T.untyped) (defaults to: nil)
  • last_modified (T.untyped) (defaults to: nil)
  • checked_at (T.untyped) (defaults to: nil)
  • updated_at (T.untyped) (defaults to: nil)


89
90
91
92
93
94
95
96
# File 'lib/active_sanction/validators.rb', line 89

def initialize(url:, etag: nil, last_modified: nil, checked_at: nil, updated_at: nil)
  @url = T.let(url!(url), String)
  @etag = T.let(string_or_nil(etag), T.nilable(String))
  @last_modified = T.let(string_or_nil(last_modified), T.nilable(String))
  @checked_at = T.let(time!(checked_at), Time)
  @updated_at = T.let(updated_at.nil? ? @checked_at : time!(updated_at), Time)
  freeze
end

Instance Attribute Details

#checked_at ⇒ Time (readonly)

Returns:

  • (Time)


54
55
56
# File 'lib/active_sanction/validators.rb', line 54

def checked_at
  @checked_at
end

#etag ⇒ String? (readonly)

Opaque, and echoed back verbatim: only the origin server can interpret either of them.

Returns:

  • (String, nil)


48
49
50
# File 'lib/active_sanction/validators.rb', line 48

def etag
  @etag
end

#last_modified ⇒ String? (readonly)

Returns:

  • (String, nil)


51
52
53
# File 'lib/active_sanction/validators.rb', line 51

def last_modified
  @last_modified
end

#updated_at ⇒ Time (readonly)

Returns:

  • (Time)


57
58
59
# File 'lib/active_sanction/validators.rb', line 57

def updated_at
  @updated_at
end

#url ⇒ String (readonly)

The URL that was requested, not the one that finally answered -- see the class comment.

Returns:

  • (String)


43
44
45
# File 'lib/active_sanction/validators.rb', line 43

def url
  @url
end

Class Method Details

.from_h(hash) ⇒ T.attached_class

Rebuilds from #to_h output, accepting string keys so validators survive the round-trip through the JSON the store writes.

Parameters:

  • hash (T.untyped)

Returns:

  • (T.attached_class)

Raises:



69
70
71
72
73
74
75
76
77
78
# File 'lib/active_sanction/validators.rb', line 69

def self.from_h(hash)
  attributes = hash.to_h.transform_keys(&:to_sym)
  unknown = attributes.keys - MEMBERS
  raise InvalidArgument, "unknown Validators attribute(s): #{unknown.join(", ")}" if unknown.any?

  # `new(**hash)` past a required keyword parameter is one of the few
  # things Sorbet cannot check statically. #initialize validates what
  # arrives, which is where a bad round-trip is caught.
  T.unsafe(self).new(**attributes)
end

.from_response(response, url:, at: nil) ⇒ T.attached_class

url is the caller's URL rather than response.uri for the reason in the class comment: the response may have come from a redirect target.

Parameters:

  • response (T.untyped)
  • url (T.untyped)
  • at (T.untyped) (defaults to: nil)

Returns:

  • (T.attached_class)


62
63
64
# File 'lib/active_sanction/validators.rb', line 62

def self.from_response(response, url:, at: nil)
  new(url: url, etag: response.etag, last_modified: response.last_modified, checked_at: at)
end

Instance Method Details

#==(other) ⇒ Boolean Also known as: eql?

Parameters:

  • other (T.untyped)

Returns:

  • (Boolean)


148
149
150
151
152
# File 'lib/active_sanction/validators.rb', line 148

def ==(other)
  return false unless other.instance_of?(self.class)

  to_h == other.to_h
end

#confirmed_by(response, at: nil) ⇒ Validators

What to store after the publisher answered 304. The copy is unchanged, so updated_at stands; only the moment we confirmed it moves. A 304 is allowed to carry a fresh ETag, and when it does that value is the one to send next time.

Parameters:

  • response (T.untyped)
  • at (T.untyped) (defaults to: nil)

Returns:



130
131
132
133
134
# File 'lib/active_sanction/validators.rb', line 130

def confirmed_by(response, at: nil)
  self.class.new(url: url, etag: response.etag || etag,
                 last_modified: response.last_modified || last_modified,
                 checked_at: at, updated_at: updated_at)
end

#empty? ⇒ Boolean

True when the publisher gave us nothing to be conditional with. Such a record is still worth storing -- it says we looked -- but it cannot save a download, so callers treat it as no validators at all.

Returns:

  • (Boolean)


102
# File 'lib/active_sanction/validators.rb', line 102

def empty? = etag.nil? && last_modified.nil?

#for?(other) ⇒ Boolean

Validators belong to the URL they came from. A publisher that moves its file gets a full download rather than a 304 from whatever is now at the old address.

Parameters:

  • other (T.untyped)

Returns:

  • (Boolean)


123
# File 'lib/active_sanction/validators.rb', line 123

def for?(other) = url == other.to_s

#hash ⇒ Integer

Returns:

  • (Integer)


156
# File 'lib/active_sanction/validators.rb', line 156

def hash = [self.class, to_h].hash

#inspect ⇒ String

Returns:

  • (String)


159
160
161
162
# File 'lib/active_sanction/validators.rb', line 159

def inspect
  "#<#{self.class} #{url} etag=#{etag.inspect} last_modified=#{last_modified.inspect} " \
    "checked_at=#{checked_at.iso8601}>"
end

#present? ⇒ Boolean

Returns:

  • (Boolean)


105
# File 'lib/active_sanction/validators.rb', line 105

def present? = !empty?

#request_headers ⇒ Hash{String => String}

Both are sent when both are known. RFC 9110 has the server prefer If-None-Match and ignore the date, but a cache in front of it may only honour one, and the second header costs 40 bytes on a request that is trying to avoid 126 MB.

Returns:

  • (Hash{String => String})


112
113
114
115
116
117
# File 'lib/active_sanction/validators.rb', line 112

def request_headers
  headers = {}
  headers["If-None-Match"] = etag if etag
  headers["If-Modified-Since"] = last_modified if last_modified
  headers.freeze
end

#to_h ⇒ Hash{Symbol => T.untyped}

Returns:

  • (Hash{Symbol => T.untyped})


137
138
139
140
141
142
143
144
145
# File 'lib/active_sanction/validators.rb', line 137

def to_h
  {
    url: url,
    etag: etag,
    last_modified: last_modified,
    checked_at: checked_at.iso8601,
    updated_at: updated_at.iso8601
  }
end