end_point_blank 0.2.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,9 +9,17 @@ module EndPointBlank
9
9
  EndPointBlank::Configuration.instance
10
10
  end
11
11
 
12
- def header(hostname = nil)
12
+ # Builds an outbound Authorization header value.
13
+ # @param base_url [String, nil] the URL you are about to call, with any
14
+ # query string and fragment removed. If given, a token covering it is
15
+ # used (minting one if necessary) and returned as a Bearer header.
16
+ # Called with no argument this is the Basic form -- which is what the
17
+ # calls to intake itself use, since intake already holds this
18
+ # service's own credential.
19
+ # @return [String] "Bearer <token>" or "Basic <credentials>"
20
+ def header(base_url = nil)
13
21
  token = nil
14
- token = EndPointBlank::AccessTokens.token(hostname) if hostname
22
+ token = EndPointBlank::AccessTokens.token(base_url) if base_url
15
23
 
16
24
  if token
17
25
  "Bearer " + token
@@ -0,0 +1,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EndPointBlank
4
+ # Resolves the base URL the caller used -- scheme, host and port -- from a
5
+ # Rack env.
6
+ #
7
+ # This deliberately does not go through Rack::Request#host / #scheme / #port.
8
+ # Rack, Express, the servlet spec and Plug each resolve "host" differently
9
+ # (Rack takes the last X-Forwarded-Host hop, Express the first, WSGI and Plug
10
+ # neither), which is precisely why the same request produced five different
11
+ # answers across the five clients. The env is read directly so that this
12
+ # algorithm is the same one implemented in the JS, Python, Java and Elixir
13
+ # libraries.
14
+ #
15
+ # Forwarded headers are honored when `trust_proxy_headers` is on, which it is
16
+ # by default: `host` was already caller-controlled in every client (all five
17
+ # read the Host header), so this opens no new hole for the field that matters
18
+ # most, and taking the LAST hop means that behind a proxy that appends, the
19
+ # value is the proxy's own observation rather than anything the caller
20
+ # planted. A directly-exposed deployment -- where nothing sits in front to
21
+ # overwrite the headers -- can set the flag to false, and then only the
22
+ # connection and the Host header are consulted.
23
+ #
24
+ # The flag arrives as an argument rather than being read from Configuration
25
+ # here, so that this module stays framework- and configuration-free and both
26
+ # states are directly testable.
27
+ module BaseUrl
28
+ HOSTNAME = /\A[a-z0-9._-]+\z/
29
+ IPV6 = /\A\[[0-9a-f:.]+\]\z/
30
+ SCHEME = /\A[a-z][a-z0-9+.-]{0,31}\z/
31
+ DEFAULT_PORTS = { "http" => 80, "https" => 443 }.freeze
32
+
33
+ module_function
34
+
35
+ # Returns a Hash carrying only the fields that resolved to a usable value.
36
+ # A field that could not be resolved is absent, never nil: the receiver has
37
+ # to be able to tell "this SDK did not report a port" from "the port is
38
+ # null".
39
+ #
40
+ # With trust_proxy_headers: false the three X-Forwarded-* headers are not
41
+ # read at all, so the request is never treated as proxied and the
42
+ # connection's scheme and port stay evidence.
43
+ def from_rack_env(env, trust_proxy_headers: true)
44
+ return {} unless env.is_a?(Hash)
45
+
46
+ forwarded_scheme = trust_proxy_headers ? clean_scheme(last_hop(env["HTTP_X_FORWARDED_PROTO"])) : nil
47
+ forwarded_host_part, forwarded_host_authority_port =
48
+ trust_proxy_headers ? split_authority(last_hop(env["HTTP_X_FORWARDED_HOST"])) : [nil, nil]
49
+ forwarded_host = clean_host(forwarded_host_part)
50
+ forwarded_port = trust_proxy_headers ? parse_port(last_hop(env["HTTP_X_FORWARDED_PORT"])) : nil
51
+
52
+ # Evidence is judged AFTER validation, not on raw header presence: a
53
+ # malformed header (e.g. "X-Forwarded-Port: not-a-port") parses to
54
+ # nothing and so must never count as proxy evidence, or an
55
+ # unauthenticated caller could blank an otherwise-valid field just by
56
+ # sending garbage.
57
+ #
58
+ # Only a forwarded scheme or port counts as evidence strong enough to
59
+ # distrust the raw connection. A forwarded Host alone does not: some
60
+ # proxies rewrite only the Host header and pass scheme/port through
61
+ # unchanged, so a valid X-Forwarded-Host by itself says nothing about
62
+ # whether the connection's own scheme/port belong to the proxy or the
63
+ # caller.
64
+ proxied = !forwarded_scheme.nil? || !forwarded_port.nil?
65
+
66
+ # A malformed X-Forwarded-Host (wrong shape, or too long -- see
67
+ # clean_host) gets the same treatment as a malformed proto or port: it
68
+ # is ignored entirely and falls back to the direct Host header, exactly
69
+ # as if the header were absent, rather than leaving host unresolved.
70
+ host_part, authority_port =
71
+ if forwarded_host
72
+ [forwarded_host_part, forwarded_host_authority_port]
73
+ else
74
+ split_authority(host_authority(env))
75
+ end
76
+
77
+ scheme = forwarded_scheme || (proxied ? nil : clean_scheme(env["rack.url_scheme"]))
78
+ host = forwarded_host || clean_host(host_part)
79
+ port_candidate = forwarded_port ||
80
+ parse_port(authority_port) ||
81
+ (proxied ? nil : parse_port(env["SERVER_PORT"]))
82
+ port = usable_port(port_candidate, scheme)
83
+
84
+ resolved = {}
85
+ resolved[:scheme] = scheme if scheme
86
+ resolved[:host] = host if host
87
+ resolved[:port] = port if port
88
+ resolved
89
+ end
90
+
91
+ # The hostname alone, for the authorize path.
92
+ #
93
+ # Deliberately NOT from_rack_env(env)[:host]: reads the Host header only,
94
+ # never the forwarded chain, however trust_proxy_headers is set. The
95
+ # value feeds `target_hostname` and the access-token cache key, and the
96
+ # portal resolves an application environment from it -- a value matching
97
+ # no registered row is a hard 422 with no fallback, not a cache miss.
98
+ #
99
+ # Composed from the same split_authority/clean_host pair from_rack_env
100
+ # uses, so IPv6 bracketing, lowercasing, and shape and length validation
101
+ # are identical between the two; only the authority's source differs.
102
+ def hostname_from_rack_env(env)
103
+ return nil unless env.is_a?(Hash)
104
+
105
+ host_part, _authority_port = split_authority(host_authority(env))
106
+ clean_host(host_part)
107
+ end
108
+
109
+ # A proxy that appends writes its own observation last. A proxy that
110
+ # overwrites (nginx, Caddy, ALB) emits one value, where first and last are
111
+ # the same thing.
112
+ def last_hop(value)
113
+ return nil unless value.is_a?(String)
114
+
115
+ hops = value.split(",").map(&:strip).reject(&:empty?)
116
+ hops.last
117
+ end
118
+
119
+ # The authority the caller named, for both resolution paths.
120
+ #
121
+ # An empty Host header is treated as ABSENT, not as a present-but-unusable
122
+ # value. A caller that sends `Host:` with nothing after it has said nothing
123
+ # about which host it meant, so there is nothing there to prefer over
124
+ # SERVER_NAME. Falling through concedes no control the caller did not
125
+ # already have either: SERVER_NAME is a server-side value, not one a
126
+ # request can steer.
127
+ #
128
+ # On the authorize path the alternative is worse than cosmetic. Resolving
129
+ # the host to nil there drops the request to Basic auth and skips the token
130
+ # mint entirely, where falling through yields a usable
131
+ # application-environment lookup key.
132
+ #
133
+ # This CHANGES Ruby's behavior: an empty Host header used to resolve the
134
+ # host to nil. `env["HTTP_HOST"] || env["SERVER_NAME"]` stops at "" because
135
+ # "" is truthy in Ruby. Python, Java and JS already fell through, because
136
+ # "" is falsy there; Ruby and Elixir stopped, because "" is truthy in both.
137
+ # One expression written five times, diverging only on the empty case. It
138
+ # now lives at one site per SDK, and this comment is why.
139
+ #
140
+ # `.to_s.empty?` so a nil Host header takes the same branch as an empty one.
141
+ def host_authority(env)
142
+ host = env["HTTP_HOST"]
143
+ host.to_s.empty? ? env["SERVER_NAME"] : host
144
+ end
145
+
146
+ # "api.example.com:8443" -> ["api.example.com", "8443"]
147
+ # "[2001:db8::1]:8443" -> ["[2001:db8::1]", "8443"]
148
+ def split_authority(value)
149
+ return [nil, nil] unless value.is_a?(String)
150
+
151
+ authority = value.strip
152
+ if authority.start_with?("[")
153
+ head, bracket, tail = authority.partition("]")
154
+ return [nil, nil] if bracket.empty?
155
+
156
+ ["#{head}]", tail.start_with?(":") ? tail[1..] : nil]
157
+ elsif authority.count(":") == 1
158
+ host, _, port = authority.partition(":")
159
+ [host, port]
160
+ else
161
+ [authority, nil]
162
+ end
163
+ end
164
+
165
+ # Normalize, then validate. "HTTPS" and "https:" both have to reach intake
166
+ # as "https": JS's location.protocol and Node's URL#protocol keep the
167
+ # colon, nothing pins the case, and intake never rewrites a stored row --
168
+ # two spellings of the same scheme would split the dominant-triple
169
+ # grouping forever. delete_suffix removes one colon, not all of them, so
170
+ # "https::" still fails the shape check rather than sneaking through.
171
+ def clean_scheme(value)
172
+ return nil unless value.is_a?(String)
173
+
174
+ scheme = value.strip.downcase.delete_suffix(":")
175
+ scheme.match?(SCHEME) ? scheme : nil
176
+ end
177
+
178
+ # DNS caps a hostname at 253 characters, and the receiving column is
179
+ # varchar(255). No web-server adapter validates the length of
180
+ # X-Forwarded-Host, so without this a caller could make the SDK report an
181
+ # arbitrarily long value; dropped, not truncated, because a truncated
182
+ # hostname is a plausible-looking WRONG one and the portal reads `host`
183
+ # verbatim to assemble a base URL.
184
+ def clean_host(value)
185
+ return nil unless value.is_a?(String)
186
+
187
+ host = value.strip.downcase
188
+ return nil if host.empty? || host.bytesize > 253
189
+
190
+ host.match?(HOSTNAME) || host.match?(IPV6) ? host : nil
191
+ end
192
+
193
+ # Numeric validation only -- 1..65535, nothing scheme-aware. Used both to
194
+ # decide whether a forwarded/authority port counts as a usable value and
195
+ # as a port candidate; default-port omission happens exactly once, in
196
+ # usable_port, against the FINAL resolved scheme, so it can never be
197
+ # skipped just because the scheme happened to resolve from a different
198
+ # source than the port did.
199
+ def parse_port(value)
200
+ port = Integer(value.to_s.strip, 10, exception: false)
201
+ return nil if port.nil? || port < 1 || port > 65_535
202
+
203
+ port
204
+ end
205
+
206
+ # A port is reported only when it can be classified against a resolved
207
+ # scheme. With no scheme, "default" is meaningless, so an unclassifiable
208
+ # port is withheld entirely rather than guessed at -- the same origin must
209
+ # never be reportable two ways depending on which headers happened to
210
+ # arrive.
211
+ def usable_port(candidate, scheme)
212
+ return nil if candidate.nil? || scheme.nil?
213
+ return nil if DEFAULT_PORTS[scheme] == candidate
214
+
215
+ candidate
216
+ end
217
+ end
218
+ end
@@ -1,5 +1,6 @@
1
1
  #!/bin/ruby
2
2
 
3
+ require 'json'
3
4
  require_relative 'http'
4
5
  require_relative 'authentication_cache'
5
6
 
@@ -19,35 +20,56 @@ module EndPointBlank
19
20
  method = request.request_method
20
21
  path = request.route_uri_pattern.to_s.gsub(/\([^)]*\)/, '')
21
22
  app_name = Configuration.instance.app_name
22
- cache_key = "epb_auth:#{client_auth}:#{path}:#{method}:#{app_name}"
23
+ # The version is part of the key because authorization is decided per
24
+ # endpoint version, and so is the deprecation carried back with it.
25
+ # Without it, two callers on different versions of the same route share
26
+ # one entry: whichever authorizes first decides both, so a client on a
27
+ # deprecated version can get no warning, or one on a current version
28
+ # can be told it is retiring.
29
+ version = VersionFinder.new.find(request)
30
+ cache_key = "epb_auth:#{client_auth}:#{path}:#{method}:#{app_name}:#{version}"
23
31
 
24
32
  cache = AuthenticationCache.instance
25
- return CachedResponse.new(201, '') if cache.exists?(cache_key)
33
+ # The cached value is the authorize response body, not a truthy
34
+ # marker.
35
+ #
36
+ # It has to be, for two reasons. Callers parse the body — a cache hit
37
+ # returning '' made JSON.parse raise, so a cached authorization became
38
+ # a 500 rather than a fast success. And the body is where the
39
+ # deprecation block lives; without it the Deprecation and Sunset
40
+ # headers would appear only on cache misses, which reads as a flaky
41
+ # feature rather than a missing one.
42
+ if (cached = cache.retrieve(cache_key))
43
+ return CachedResponse.new(201, cached)
44
+ end
26
45
 
27
- hostname = request.host
28
- auth = Authorization.header(hostname)
46
+ # Host header only, never the forwarded chain -- see
47
+ # BaseUrl.hostname_from_rack_env. request.host would read
48
+ # X-Forwarded-Host unconditionally.
49
+ hostname = EndPointBlank::BaseUrl.hostname_from_rack_env(request.env)
29
50
  body = {
30
51
  path: path,
31
52
  http_method: method,
32
53
  client_auth: client_auth,
33
54
  target_hostname: hostname,
34
55
  application: app_name,
35
- endpoint_version: VersionFinder.new.find(request),
56
+ endpoint_version: version,
36
57
  source_ip: request.remote_ip,
37
58
  uuid: request.uuid
38
59
  }
39
- response = Http.post(configuration.authorize_url, auth, body)
40
60
 
41
- if response&.status == 401 && auth.to_s.start_with?("Bearer ")
42
- EndPointBlank::AccessTokens.instance.remove(hostname)
43
- auth = Authorization.header(hostname)
44
- response = Http.post(configuration.authorize_url, auth, body)
45
- end
61
+ # Basic, not Bearer. This call is to intake, which already holds
62
+ # this service's credential -- minting a token to present it back
63
+ # was a hop that bought nothing. With no Bearer there is no stale
64
+ # token, so the 401 retry that used to live here is gone: a 401 now
65
+ # means the credential is wrong, which is worth surfacing rather
66
+ # than retrying.
67
+ response = Http.post(configuration.authorize_url, Authorization.header, body)
46
68
 
47
69
  return nil if response.nil?
48
70
  EndPointBlank.logger.info "Authentication response: #{response.status} - #{response.body}"
49
71
  if response.status == 201
50
- cache.store(cache_key, true)
72
+ cache.store(cache_key, response.body)
51
73
  elsif response.status > 299
52
74
  EndPointBlank.logger.error "Failed to authorize endpoint: #{response.status} - #{response.body}"
53
75
  end
@@ -110,7 +110,7 @@ module EndPointBlank
110
110
  nil
111
111
  else
112
112
  controller_class = "#{route.defaults[:controller].camelize}Controller".constantize
113
- versions = controller_class.respond_to?(:versions) ? controller_class.versions(route.defaults[:action].to_sym) : {}
113
+ versions = controller_class.respond_to?(:versions) ? controller_class.versions(route.defaults[:action].to_sym) : []
114
114
  {
115
115
  path: route.path.spec.to_s.gsub(/\([^)]*\)/, ''),
116
116
  http_method: route.verb,
@@ -12,8 +12,8 @@ module EndPointBlank
12
12
  EndPointBlank::Configuration.instance
13
13
  end
14
14
 
15
- def token(hostname)
16
- body = {hostname: hostname}
15
+ def token(base_url)
16
+ body = {base_url: base_url}
17
17
  if configuration.token_ttl
18
18
  body[:token_ttl] = configuration.token_ttl
19
19
  end
@@ -23,7 +23,7 @@ module EndPointBlank
23
23
  body: body.to_json,
24
24
  **EndPointBlank::Commands::Http::TIMEOUT_OPTIONS
25
25
  )
26
- EndPointBlank.logger.info "Authentication response: #{response.status} - #{response.body}"
26
+ EndPointBlank.logger.info "Authentication response: #{response.status}"
27
27
  parsed_body = response.body.is_a?(String) ? JSON.parse(response.body) : response.body
28
28
  parsed_body.transform_keys(&:to_sym)
29
29
  rescue => e
@@ -15,7 +15,7 @@ module EndPointBlank
15
15
 
16
16
  attr_accessor :worker_count, :log_mode,
17
17
  :version_finder, :application_version, :token_ttl, :cache_ttl,
18
- :masking_rules, :mask_hook, :logger
18
+ :masking_rules, :mask_hook, :logger, :trust_proxy_headers
19
19
 
20
20
  def initialize
21
21
  @worker_count = 4
@@ -23,6 +23,7 @@ module EndPointBlank
23
23
  @cache_ttl = 300
24
24
  @masking_rules = []
25
25
  @mask_hook = nil
26
+ @trust_proxy_headers = true
26
27
  end
27
28
 
28
29
  # Returns the configured client id, falling back to the
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module EndPointBlank
6
+ # Formats the deprecation facts returned by an authorize call into the
7
+ # standard response headers.
8
+ #
9
+ # Deprecation RFC 9745 — an Item Structured Header Date: "@1688169599"
10
+ # Sunset RFC 8594 — an HTTP-date: "Sat, 31 Dec 2018 23:59:59 GMT"
11
+ #
12
+ # RFC 9745 permits a past value ("was deprecated at that date"), which is what
13
+ # EndPointBlank emits: deprecation takes effect when it is declared.
14
+ #
15
+ # Pure and stateless on purpose. The SDK does not know what a lifecycle is —
16
+ # it relays two timestamps the portal already decided about, and this turns
17
+ # them into two strings. That keeps the vectors in
18
+ # docs/superpowers/specs/2026-08-01-header-vectors.md assertable without
19
+ # constructing a request.
20
+ module DeprecationHeaders
21
+ DEPRECATION = "Deprecation"
22
+ SUNSET = "Sunset"
23
+
24
+ # Fixed English abbreviations. Ruby's %a/%b are locale-independent, but
25
+ # spelling them out removes the question entirely — a server running under a
26
+ # different locale must still emit an HTTP-date.
27
+ DAYS = %w[Sun Mon Tue Wed Thu Fri Sat].freeze
28
+ MONTHS = %w[Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec].freeze
29
+
30
+ class << self
31
+ # @param deprecation [Hash, nil] the authorize response's "deprecation"
32
+ # block: {"deprecated_at" => iso8601, "sunset_at" => iso8601 | nil}
33
+ # @return [Hash] header name => value; empty when there is nothing to say
34
+ def build(deprecation)
35
+ return {} unless deprecation.is_a?(Hash)
36
+
37
+ headers = {}
38
+
39
+ if (at = parse(deprecation["deprecated_at"] || deprecation[:deprecated_at]))
40
+ headers[DEPRECATION] = deprecation_value(at)
41
+ end
42
+
43
+ if (at = parse(deprecation["sunset_at"] || deprecation[:sunset_at]))
44
+ headers[SUNSET] = sunset_value(at)
45
+ end
46
+
47
+ headers
48
+ end
49
+
50
+ # "@1688169599" — no quotes, no sub-second precision.
51
+ def deprecation_value(time)
52
+ "@#{time.to_i}"
53
+ end
54
+
55
+ # "Sat, 31 Dec 2018 23:59:59 GMT" — day-of-month zero padded, always GMT.
56
+ def sunset_value(time)
57
+ t = time.utc
58
+ format(
59
+ "%s, %02d %s %04d %02d:%02d:%02d GMT",
60
+ DAYS[t.wday], t.day, MONTHS[t.month - 1], t.year, t.hour, t.min, t.sec
61
+ )
62
+ end
63
+
64
+ private
65
+
66
+ # Never raise into the provider's response path. A malformed timestamp
67
+ # from the portal is a bug worth no header; it is not worth a 500 on a
68
+ # request that already succeeded.
69
+ #
70
+ # Types are matched rather than coerced. `Time.parse(12345)` does not
71
+ # raise — it happily returns a date in 2012 — so a `to_s` here would turn
72
+ # a nonsense value into a plausible-looking header, which is the one
73
+ # outcome worse than no header at all.
74
+ def parse(value)
75
+ case value
76
+ when Time then value.utc
77
+ when String then parse_string(value)
78
+ end
79
+ end
80
+
81
+ def parse_string(value)
82
+ return nil if value.empty?
83
+
84
+ Time.parse(value).utc
85
+ rescue ArgumentError, TypeError
86
+ nil
87
+ end
88
+ end
89
+ end
90
+ end
@@ -19,6 +19,14 @@ module EndPointBlank
19
19
 
20
20
  status, headers, body = @app.call(env)
21
21
 
22
+ # RFC 9745 / RFC 8594. The provider writes no code for this: the
23
+ # portal decided the version is deprecated, authorize relayed the
24
+ # dates, and this attaches them to the response their consumer
25
+ # already receives.
26
+ ::EndPointBlank::DeprecationHeaders
27
+ .build(::EndPointBlank::Rack::EnvStore.deprecation)
28
+ .each { |name, value| headers[name] = value }
29
+
22
30
  [status, headers, body]
23
31
  rescue ::EndPointBlank::UnauthorizedError => e
24
32
  # We don't want to log unauthorized errors as they are expected to happen
@@ -1,8 +1,28 @@
1
1
  module EndPointBlank
2
2
  module Rack
3
+ # Per-request state, keyed off the Rack env.
4
+ #
5
+ # Only the env itself is held in a thread-local; everything derived from a
6
+ # request lives *inside* that env. That distinction is the whole point.
7
+ #
8
+ # Threads are reused between requests, so a thread-local is correct only for
9
+ # as long as something reliably clears it — here, the middleware's `ensure`.
10
+ # Anything that set a value without that middleware in the stack would
11
+ # strand it on the thread, and the next request served by that thread could
12
+ # read the previous caller's data: their app-environment id on our audit
13
+ # row, their sunset date on our response.
14
+ #
15
+ # The env is per-request by construction. The server builds a fresh hash per
16
+ # request and `set/1` replaces it wholesale at the top of the middleware, so
17
+ # a stranded value is unreachable even if `clear/0` never runs. Correctness
18
+ # stops depending on cleanup happening.
19
+ #
20
+ # Outside a Rack request there is nowhere to put anything and no response to
21
+ # decorate, so the writers are no-ops and the readers return nil.
3
22
  class EnvStore
4
23
  KEY = 'end_point_blank.rack_env'.freeze
5
24
  SOURCE_ENV_ID_KEY = 'end_point_blank.source_application_environment_id'.freeze
25
+ DEPRECATION_KEY = 'end_point_blank.deprecation'.freeze
6
26
 
7
27
  def self.set(env)
8
28
  Thread.current[KEY] = env
@@ -17,18 +37,42 @@ module EndPointBlank
17
37
  env && ::Rack::Request.new(env)
18
38
  end
19
39
 
40
+ # The calling service's application environment, resolved by `authorize!`
41
+ # and stamped onto every audit row for this request.
20
42
  def self.set_source_application_environment_id(id)
21
- Thread.current[SOURCE_ENV_ID_KEY] = id
43
+ put(SOURCE_ENV_ID_KEY, id)
22
44
  end
23
45
 
24
46
  def self.source_application_environment_id
25
- Thread.current[SOURCE_ENV_ID_KEY]
47
+ fetch(SOURCE_ENV_ID_KEY)
48
+ end
49
+
50
+ # The authorize response's deprecation block, stashed on the way in so the
51
+ # response middleware can turn it into `Deprecation` and `Sunset` headers
52
+ # on the way out.
53
+ def self.set_deprecation(deprecation)
54
+ put(DEPRECATION_KEY, deprecation)
55
+ end
56
+
57
+ def self.deprecation
58
+ fetch(DEPRECATION_KEY)
26
59
  end
27
60
 
28
61
  def self.clear
29
62
  Thread.current[KEY] = nil
30
- Thread.current[SOURCE_ENV_ID_KEY] = nil
31
63
  end
64
+
65
+ def self.put(key, value)
66
+ env = get
67
+ env[key] = value if env
68
+ end
69
+ private_class_method :put
70
+
71
+ def self.fetch(key)
72
+ env = get
73
+ env && env[key]
74
+ end
75
+ private_class_method :fetch
32
76
  end
33
77
  end
34
- end
78
+ end
@@ -3,6 +3,8 @@ module EndPointBlank
3
3
  module Headers
4
4
  def self.extract
5
5
  env = ::EndPointBlank::Rack::EnvStore.get
6
+ return {} if env.nil?
7
+
6
8
  env.select { |k,v| k.start_with? 'HTTP_'}.
7
9
  transform_keys { |k| k.sub(/^HTTP_/, '').split('_').map(&:capitalize).join('-') }
8
10
  end
@@ -17,6 +17,9 @@ module EndPointBlank
17
17
  result_json = JSON.parse(result.body)
18
18
  app_env_id = result_json['data'][0]['source_application_environment_id']
19
19
  ::EndPointBlank::Rack::EnvStore.set_source_application_environment_id(app_env_id)
20
+ # Present only when the called version is deprecated; the middleware
21
+ # turns it into Deprecation / Sunset headers on the way out.
22
+ ::EndPointBlank::Rack::EnvStore.set_deprecation(result_json['deprecation'])
20
23
  end
21
24
 
22
25
  private
@@ -4,15 +4,20 @@ module EndPointBlank
4
4
  extend ActiveSupport::Concern
5
5
 
6
6
  class_methods do
7
- # Define versioning for specific actions
7
+ # Record which versions specific actions serve.
8
+ #
9
+ # Lifecycle state (Current, Deprecated, ...) is NOT declared here. It is
10
+ # managed in the EndPointBlank portal, where changing it does not require
11
+ # shipping code. This reports which versions exist, not what they mean.
12
+ #
8
13
  # @param versions [Array<String>] List of version strings (e.g., ["v1", "v2"])
9
14
  # @param options [Hash] Options hash with :only or :except keys
10
15
  # @option options [Array<Symbol>] :only Actions to include in versioning
11
16
  # @option options [Array<Symbol>] :except Actions to exclude from versioning
12
17
  #
13
18
  # Example:
14
- # version ["v1", "v2"], only: [:index], state: "Current"
15
- # version ["v3"], except: [:destroy], state: "Deprecated"
19
+ # version ["v1", "v2"], only: [:index]
20
+ # version ["v3"], except: [:destroy]
16
21
  def version(values, options = {})
17
22
  versions = Array(values)
18
23
  @versioning_config ||= {}
@@ -20,23 +25,16 @@ module EndPointBlank
20
25
  actions = determine_actions(options)
21
26
 
22
27
  actions.each do |action|
23
- @versioning_config[action] ||= {}
24
- state = options[:state] || "__default__"
25
- @versioning_config[action][state] ||= []
26
- @versioning_config[action][state] = (@versioning_config[action][state] + versions).uniq
28
+ # uniq preserves declaration order, so the manifest stays stable
29
+ # between deploys instead of churning.
30
+ @versioning_config[action] = ((@versioning_config[action] || []) + versions).uniq
27
31
  end
28
32
  end
29
33
 
30
- # Returns the versioning configuration hash
31
- # @return [Hash] Hash mapping action names to their version arrays
32
- #
33
- # Example return value:
34
- # {
35
- # index: ["v1", "v2"],
36
- # show: ["v1"]
37
- # }
34
+ # Versions the given action serves.
35
+ # @return [Array<String>] e.g. ["v1", "v2"]; empty when none declared.
38
36
  def versions(action)
39
- @versioning_config&.fetch(action, {}) || {}
37
+ @versioning_config&.fetch(action, []) || []
40
38
  end
41
39
 
42
40
  private
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module EndPointBlank
4
- VERSION = "0.2.1"
4
+ VERSION = "0.6.0"
5
5
  end
@@ -26,11 +26,10 @@ module EndPointBlank
26
26
  version = Commands::VersionFinder.new.find(request)
27
27
  headers = ::EndPointBlank::Rack::Headers.extract
28
28
 
29
- payload = {
29
+ {
30
30
  app_name: EndPointBlank::Configuration.instance.app_name,
31
31
  env: SessionConfiguration.env_name,
32
32
  uuid: request_uuid(env),
33
- host: request.host,
34
33
  status: request_status(request),
35
34
  headers: headers,
36
35
  path: request.path,
@@ -38,7 +37,12 @@ module EndPointBlank
38
37
  endpoint_version: version,
39
38
  request: request_body(request),
40
39
  sent_at: Time.now.utc.iso8601(3)
41
- }
40
+ }.merge(
41
+ ::EndPointBlank::BaseUrl.from_rack_env(
42
+ env,
43
+ trust_proxy_headers: EndPointBlank::Configuration.instance.trust_proxy_headers
44
+ )
45
+ )
42
46
  end
43
47
 
44
48
  def write()
@@ -36,6 +36,7 @@ module EndPointBlank
36
36
  body: truncate(normalize_body(body)),
37
37
  sent_at: Time.now.utc.iso8601(3),
38
38
  route: route,
39
+ method: request&.request_method,
39
40
  data: data,
40
41
  source_application_environment_id: source_application_environment_id
41
42
  }