Skip to content

Commit 0e62f43

Browse files
committed
feat(feeds): negotiate rss or json feed output by extension and Accept
1 parent d180d14 commit 0e62f43

23 files changed

Lines changed: 823 additions & 144 deletions

app.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@
1515
require_relative 'app/cache_ttl'
1616
require_relative 'app/exceptions'
1717
require_relative 'app/xml_builder'
18+
require_relative 'app/json_feed_builder'
19+
require_relative 'app/feed_response_format'
20+
require_relative 'app/request_target'
21+
require_relative 'app/feed_render_result'
1822
require_relative 'app/error_responder'
1923
require_relative 'app/security_logger'
2024
require_relative 'app/observability'

app/api/v1/feeds.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ module Feeds
1717
class << self
1818
# @param request [Rack::Request]
1919
# @param token [String]
20-
# @return [String] XML feed body.
20+
# @return [String] serialized feed body.
2121
def show(request, token)
2222
ShowFeed.call(request, token)
2323
end

app/api/v1/feeds/show_feed.rb

Lines changed: 14 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
require_relative '../../../account_manager'
44
require_relative '../../../auth'
55
require_relative '../../../auto_source'
6-
require_relative '../../../cache_ttl'
76
require_relative '../../../exceptions'
7+
require_relative '../../../feed_response_format'
88
require_relative '../../../feed_generator'
99
require_relative '../../../http_cache'
1010
require_relative '../../../observability'
@@ -21,17 +21,17 @@ module Feeds
2121
# This module stays narrow by handling only edge validation and
2222
# orchestration, then delegating generation to existing services.
2323
module ShowFeed
24-
DEFAULT_TTL_SECONDS = 3600
25-
2624
class << self
2725
# Resolves and renders XML feed output for a token request.
2826
#
2927
# @param request [Rack::Request]
3028
# @param token [String] signed public feed token.
31-
# @return [String] XML feed response body.
29+
# @return [String] serialized feed response body.
3230
def call(request, token)
33-
feed_token, strategy = resolve_authorized_feed(token)
34-
rendered = render_generated_feed(request, feed_token.url, strategy)
31+
format = FeedResponseFormat.for_request(request)
32+
normalized_token = FeedResponseFormat.strip_known_extension(token)
33+
feed_token, strategy = resolve_authorized_feed(normalized_token)
34+
rendered = render_generated_feed(request, feed_token.url, strategy, format)
3535
emit_render_success(strategy, feed_token.url)
3636
rendered
3737
rescue StandardError => error
@@ -97,22 +97,16 @@ def default_strategy
9797
# @param request [Rack::Request]
9898
# @param url [String]
9999
# @param strategy [String]
100-
# @return [String] rendered XML.
101-
def render_generated_feed(request, url, strategy)
102-
feed_object = AutoSource.generate_feed_object(url, strategy)
103-
rendered_feed = FeedGenerator.process_feed_content(url, strategy, feed_object)
104-
105-
request.response['Content-Type'] = 'application/xml'
106-
HttpCache.expires(request.response, ttl_from_feed(feed_object), cache_control: 'public')
100+
# @param format [Symbol]
101+
# @return [String] rendered feed body.
102+
def render_generated_feed(request, url, strategy, format)
103+
rendered_feed = AutoSource.generate_feed_result(url, strategy, format:)
107104

108-
rendered_feed.to_s
109-
end
105+
request.response['Content-Type'] = FeedResponseFormat.content_type(format)
106+
HttpCache.expires(request.response, rendered_feed.ttl_seconds, cache_control: 'public')
107+
HttpCache.vary(request.response, 'Accept')
110108

111-
# @param feed_object [Object] object exposing channel ttl when available.
112-
# @return [Integer] cache TTL in seconds.
113-
def ttl_from_feed(feed_object)
114-
ttl_value = feed_object.respond_to?(:channel) ? feed_object.channel&.ttl : nil
115-
CacheTtl.seconds_from_minutes(ttl_value, default: DEFAULT_TTL_SECONDS)
109+
rendered_feed.body
116110
end
117111

118112
# @param token [String]

app/auto_source.rb

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
require_relative 'account_manager'
55
require_relative 'auth'
66
require_relative 'boundary_models'
7+
require_relative 'feed_response_format'
78
require_relative 'feed_generator'
89
require_relative 'url_validator'
910

@@ -64,17 +65,26 @@ def generate_feed_from_stable_id(feed_id, token_data)
6465

6566
# @param url [String]
6667
# @param strategy [String]
68+
# @param format [Symbol]
69+
# @return [Html2rss::Web::FeedRenderResult]
70+
def generate_feed_result(url, strategy = 'ssrf_filter', format: FeedResponseFormat::RSS)
71+
FeedGenerator.generate_feed_result(url, strategy, format:)
72+
end
73+
74+
# @param url [String]
75+
# @param strategy [String]
76+
# @param format [Symbol]
6777
# @return [Object] raw feed object from selected strategy.
68-
def generate_feed_object(url, strategy = 'ssrf_filter')
69-
FeedGenerator.call_strategy(url, strategy)
78+
def generate_feed_object(url, strategy = 'ssrf_filter', format: FeedResponseFormat::RSS)
79+
FeedGenerator.call_strategy(url, strategy, format:)
7080
end
7181

7282
# @param url [String]
7383
# @param strategy [String]
84+
# @param format [Symbol]
7485
# @return [String] rendered RSS/XML content.
75-
def generate_feed_content(url, strategy = 'ssrf_filter')
76-
feed_content = FeedGenerator.call_strategy(url, strategy)
77-
FeedGenerator.process_feed_content(url, strategy, feed_content)
86+
def generate_feed_content(url, strategy = 'ssrf_filter', format: FeedResponseFormat::RSS)
87+
generate_feed_result(url, strategy, format:).body
7888
end
7989

8090
private

app/error_responder.rb

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
# frozen_string_literal: true
22

33
require_relative 'api/v1/contract'
4+
require_relative 'feed_response_format'
5+
require_relative 'json_feed_builder'
46
require_relative 'observability'
7+
require_relative 'request_target'
58

69
module Html2rss
710
module Web
@@ -27,6 +30,7 @@ def respond(request:, response:, error:)
2730

2831
client_message = client_message_for(error)
2932

33+
return render_feed_error(request, response, client_message) if RequestTarget.feed?(request)
3034
return render_api_error(response, client_message, error_code) if api_request?(request)
3135

3236
render_xml_error(response, client_message)
@@ -35,8 +39,14 @@ def respond(request:, response:, error:)
3539
private
3640

3741
# @param request [Rack::Request]
38-
# @return [Boolean] true when request path is within API v1.
42+
# @return [Boolean]
3943
def api_request?(request)
44+
RequestTarget.api?(request) || api_path?(request)
45+
end
46+
47+
# @param request [Rack::Request]
48+
# @return [Boolean]
49+
def api_path?(request)
4050
path = request.path.to_s
4151
path == API_ROOT_PATH || path.start_with?("#{API_ROOT_PATH}/")
4252
end
@@ -50,6 +60,17 @@ def render_api_error(response, message, code)
5060
JSON.generate({ success: false, error: { message: message, code: code } })
5161
end
5262

63+
# @param response [Rack::Response]
64+
# @param message [String]
65+
# @return [String] negotiated feed error payload.
66+
def render_feed_error(request, response, message)
67+
format = FeedResponseFormat.for_request(request)
68+
response['Content-Type'] = FeedResponseFormat.content_type(format)
69+
return JsonFeedBuilder.build_error_feed(message: message) if format == FeedResponseFormat::JSON_FEED
70+
71+
XmlBuilder.build_error_feed(message: message)
72+
end
73+
5374
# @param response [Rack::Response]
5475
# @param message [String]
5576
# @return [String] XML error feed.

app/feed_accept_header.rb

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
# frozen_string_literal: true
2+
3+
module Html2rss
4+
module Web
5+
##
6+
# Parses Accept headers for feed representation negotiation.
7+
module FeedAcceptHeader
8+
MediaRange = Data.define(:type, :subtype, :quality, :position) do
9+
# @return [Integer]
10+
def specificity
11+
return 0 if type == '*' && subtype == '*'
12+
return 1 if subtype == '*'
13+
14+
2
15+
end
16+
17+
# @param candidate [String]
18+
# @return [Boolean]
19+
def matches?(candidate)
20+
candidate_type, candidate_subtype = candidate.downcase.split('/', 2)
21+
return true if type == '*' && subtype == '*'
22+
return candidate_type == type if subtype == '*'
23+
24+
candidate_type == type && candidate_subtype == subtype
25+
end
26+
end
27+
28+
class << self
29+
# @param accept_header [String, nil]
30+
# @param json_media_types [Array<String>]
31+
# @param rss_media_types [Array<String>]
32+
# @return [Symbol, nil]
33+
def preferred_format(accept_header, json_media_types:, rss_media_types:)
34+
media_ranges = parse(accept_header)
35+
return nil if media_ranges.empty?
36+
37+
json_score = best_score(media_ranges, json_media_types)
38+
rss_score = best_score(media_ranges, rss_media_types)
39+
40+
return nil unless json_score
41+
return FeedResponseFormat::JSON_FEED if rss_score.nil?
42+
43+
(json_score <=> rss_score)&.positive? ? FeedResponseFormat::JSON_FEED : nil
44+
end
45+
46+
private
47+
48+
# @param accept_header [String, nil]
49+
# @return [Array<MediaRange>]
50+
def parse(accept_header)
51+
accept_header.to_s.split(',').filter_map.with_index do |raw_range, position|
52+
build_media_range(raw_range, position)
53+
end
54+
end
55+
56+
# @param raw_range [String]
57+
# @param position [Integer]
58+
# @return [MediaRange, nil]
59+
def build_media_range(raw_range, position)
60+
media_type, *parameter_parts = raw_range.strip.downcase.split(';')
61+
type, subtype = media_type.to_s.split('/', 2)
62+
return if type.to_s.empty? || subtype.to_s.empty?
63+
64+
MediaRange.new(
65+
type:,
66+
subtype:,
67+
quality: extract_quality(parameter_parts),
68+
position:
69+
)
70+
end
71+
72+
# @param parameter_parts [Array<String>]
73+
# @return [Float]
74+
def extract_quality(parameter_parts)
75+
raw_value = parameter_parts
76+
.map(&:strip)
77+
.find { |part| part.start_with?('q=') }
78+
&.split('=', 2)
79+
&.last
80+
quality = raw_value ? Float(raw_value) : 1.0
81+
quality.clamp(0.0, 1.0)
82+
rescue ArgumentError
83+
1.0
84+
end
85+
86+
# @param media_ranges [Array<MediaRange>]
87+
# @param candidates [Array<String>]
88+
# @return [Array(Float, Integer, Integer), nil]
89+
def best_score(media_ranges, candidates)
90+
media_ranges
91+
.filter { |range| range.quality.positive? && candidates.any? { |candidate| range.matches?(candidate) } }
92+
.map { |range| [range.quality, range.specificity, -range.position] }
93+
.max
94+
end
95+
end
96+
end
97+
end
98+
end

0 commit comments

Comments
 (0)