Skip to content

Commit b5d554c

Browse files
committed
feat!: route feeds through shared synchronous service
1 parent eddaa09 commit b5d554c

4 files changed

Lines changed: 91 additions & 114 deletions

File tree

app/api/v1/feeds/show_feed.rb

Lines changed: 41 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@
22

33
require_relative '../../../account_manager'
44
require_relative '../../../auth'
5-
require_relative '../../../auto_source'
65
require_relative '../../../exceptions'
76
require_relative '../../../feed_response_format'
8-
require_relative '../../../feed_generator'
7+
require_relative '../../../feeds/json_renderer'
8+
require_relative '../../../feeds/request_parser'
9+
require_relative '../../../feeds/resolver'
10+
require_relative '../../../feeds/rss_renderer'
11+
require_relative '../../../feeds/service'
912
require_relative '../../../http_cache'
1013
require_relative '../../../observability'
11-
require_relative '../../../url_validator'
1214

1315
module Html2rss
1416
module Web
@@ -28,97 +30,61 @@ class << self
2830
# @param token [String] signed public feed token.
2931
# @return [String] serialized feed response body.
3032
def call(request, token)
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)
35-
emit_render_success(strategy, feed_token.url)
36-
rendered
33+
feed_request, resolved_source, result = feed_pipeline(request, token)
34+
configure_response(request, feed_request.representation, result.ttl_seconds)
35+
emit_success_from(resolved_source)
36+
render_result(result, feed_request.representation)
3737
rescue StandardError => error
3838
emit_render_failure(error)
3939
raise
4040
end
4141

4242
private
4343

44-
# @return [void]
45-
def ensure_auto_source_enabled!
46-
raise ForbiddenError, Contract::MESSAGES[:auto_source_disabled] unless AutoSource.enabled?
47-
end
48-
44+
# @param request [Rack::Request]
4945
# @param token [String]
50-
# @return [Html2rss::Web::FeedToken]
51-
def validated_token_for(token)
52-
feed_token = Auth.validate_and_decode_feed_token(token)
53-
raise UnauthorizedError, 'Invalid token' unless feed_token
54-
55-
feed_token
56-
end
57-
58-
# @param feed_token [Html2rss::Web::FeedToken]
59-
# @return [Hash{Symbol=>Object}] account attributes.
60-
def account_for(feed_token)
61-
account = AccountManager.get_account_by_username(feed_token.username)
62-
raise UnauthorizedError, 'Account not found' unless account
63-
64-
account
65-
end
66-
67-
# @param account [Hash{Symbol=>Object}]
68-
# @param url [String]
69-
# @return [void]
70-
def ensure_access!(account, url)
71-
raise ForbiddenError, 'Access Denied' unless UrlValidator.url_allowed?(account, url)
72-
end
73-
74-
# @param feed_token [Html2rss::Web::FeedToken]
75-
# @return [String] validated strategy identifier.
76-
def resolve_token_strategy(feed_token)
77-
strategy = feed_token.strategy.to_s.strip
78-
strategy = default_strategy if strategy.empty?
79-
80-
raise BadRequestError, 'Unsupported strategy' unless supported_strategies.include?(strategy)
81-
82-
strategy
83-
end
84-
85-
# @return [Array<String>] supported strategy identifiers.
86-
def supported_strategies
87-
Html2rss::RequestService.strategy_names.map(&:to_s)
88-
end
46+
# @return [Array<(Html2rss::Web::Feeds::Request, Html2rss::Web::Feeds::ResolvedSource, Html2rss::Web::Feeds::Result)>]
47+
def feed_pipeline(request, token)
48+
feed_request = ::Html2rss::Web::Feeds::RequestParser.call(
49+
request: request,
50+
target_kind: :token,
51+
identifier: token
52+
)
53+
resolved_source = ::Html2rss::Web::Feeds::Resolver.call(feed_request)
54+
result = ::Html2rss::Web::Feeds::Service.call(resolved_source)
55+
raise InternalServerError, result.message if result.status == :error
8956

90-
# @return [String] default strategy identifier.
91-
def default_strategy
92-
Html2rss::RequestService.default_strategy_name.to_s
57+
[feed_request, resolved_source, result]
9358
end
9459

95-
# Builds HTTP response headers and returns XML body.
96-
#
9760
# @param request [Rack::Request]
98-
# @param url [String]
99-
# @param strategy [String]
10061
# @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:)
104-
62+
# @param ttl_seconds [Integer]
63+
# @return [void]
64+
def configure_response(request, format, ttl_seconds)
10565
request.response['Content-Type'] = FeedResponseFormat.content_type(format)
106-
HttpCache.expires(request.response, rendered_feed.ttl_seconds, cache_control: 'public')
66+
HttpCache.expires(request.response, ttl_seconds, cache_control: 'public')
10767
HttpCache.vary(request.response, 'Accept')
68+
end
10869

109-
rendered_feed.body
70+
# @param resolved_source [Html2rss::Web::Feeds::ResolvedSource]
71+
# @return [void]
72+
def emit_success_from(resolved_source)
73+
emit_render_success(
74+
resolved_source.generator_input[:strategy],
75+
resolved_source.generator_input.dig(:channel, :url)
76+
)
11077
end
11178

112-
# @param token [String]
113-
# @return [Array<(Html2rss::Web::FeedToken, String)>]
114-
def resolve_authorized_feed(token)
115-
feed_token = validated_token_for(token)
116-
account = account_for(feed_token)
117-
ensure_access!(account, feed_token.url)
118-
ensure_auto_source_enabled!
79+
# @param result [Html2rss::Web::Feeds::Result]
80+
# @param format [Symbol]
81+
# @return [String]
82+
def render_result(result, format)
83+
if format == ::Html2rss::Web::Feeds::ResponseFormat::JSON_FEED
84+
return ::Html2rss::Web::Feeds::JsonRenderer.call(result)
85+
end
11986

120-
strategy = resolve_token_strategy(feed_token)
121-
[feed_token, strategy]
87+
::Html2rss::Web::Feeds::RssRenderer.call(result)
12288
end
12389

12490
# @param strategy [String]

app/feed_route_handler.rb

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
# frozen_string_literal: true
22

33
require_relative 'feed_response_format'
4+
require_relative 'feeds/json_renderer'
5+
require_relative 'feeds/request_parser'
6+
require_relative 'feeds/resolver'
7+
require_relative 'feeds/rss_renderer'
8+
require_relative 'feeds/service'
49
require_relative 'http_cache'
510

611
module Html2rss
@@ -14,11 +19,15 @@ class << self
1419
# @param feed_name [String]
1520
# @return [String]
1621
def call(context:, router:, feed_name:)
17-
format = FeedResponseFormat.for_request(router)
18-
content, ttl_seconds = fetch_feed_payload(context, router, feed_name, format)
19-
emit_success(context, feed_name, router.params['strategy'])
20-
configure_response(router, ttl_seconds, format)
21-
content
22+
feed_request = Feeds::RequestParser.call(request: router, target_kind: :static, identifier: feed_name)
23+
resolved_source = Feeds::Resolver.call(feed_request)
24+
result = Feeds::Service.call(resolved_source)
25+
26+
raise InternalServerError, result.message if result.status == :error
27+
28+
emit_success(context, feed_name, resolved_source.generator_input[:strategy])
29+
configure_response(router, result.ttl_seconds, feed_request.representation)
30+
render_result(result, feed_request.representation)
2231
rescue StandardError => error
2332
emit_failure(context, feed_name, error)
2433
raise
@@ -52,18 +61,13 @@ def emit_failure(context, feed_name, error)
5261
)
5362
end
5463

55-
# @param context [Html2rss::Web::AppContext::Context]
56-
# @param router [Roda::RodaRequest]
57-
# @param feed_name [String]
64+
# @param result [Html2rss::Web::Feeds::Result]
5865
# @param format [Symbol]
59-
# @return [Array<(String, Integer)>]
60-
def fetch_feed_payload(context, router, feed_name, format)
61-
context.feed_request_handler.call(
62-
feed_name: feed_name,
63-
params: router.params,
64-
format: format,
65-
async_refresh: context.flags.async_feed_refresh_enabled?
66-
)
66+
# @return [String]
67+
def render_result(result, format)
68+
return Feeds::JsonRenderer.call(result) if format == Feeds::ResponseFormat::JSON_FEED
69+
70+
Feeds::RssRenderer.call(result)
6771
end
6872

6973
# @param router [Roda::RodaRequest]

spec/html2rss/web/api/v1_spec.rb

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,14 @@
99

1010
def app = Html2rss::Web::App.freeze.app
1111
def json_feed_error = JSON.parse(last_response.body).slice('version', 'title')
12-
def rss_result = Html2rss::Web::FeedRenderResult.new(body: '<rss version="2.0"></rss>', ttl_seconds: 600)
1312

14-
def json_result
15-
Html2rss::Web::FeedRenderResult.new(
16-
body: '{"version":"https://jsonfeed.org/version/1.1","items":[]}',
17-
ttl_seconds: 600
13+
def feed_result
14+
Html2rss::Web::Feeds::Result.new(
15+
status: :ok,
16+
payload: nil,
17+
message: nil,
18+
ttl_seconds: 600,
19+
cache_key: 'feed_result:test'
1820
)
1921
end
2022

@@ -185,10 +187,11 @@ def json_result
185187
expect(last_response.body).to include('Account not found')
186188
end
187189

188-
it 'renders feed for a valid token', :aggregate_failures do
190+
it 'renders feed for a valid token', :aggregate_failures do # rubocop:disable RSpec/ExampleLength
189191
token = Html2rss::Web::Auth.generate_feed_token('admin', feed_url, strategy: 'ssrf_filter')
190192

191-
allow(Html2rss::Web::AutoSource).to receive(:generate_feed_result).and_return(rss_result)
193+
allow(Html2rss::Web::Feeds::Service).to receive(:call).and_return(feed_result)
194+
allow(Html2rss::Web::Feeds::RssRenderer).to receive(:call).and_return('<rss version="2.0"></rss>')
192195

193196
get "/api/v1/feeds/#{token}.xml"
194197

@@ -199,7 +202,9 @@ def json_result
199202
it 'renders json feed for a valid token when requested through Accept', :aggregate_failures do # rubocop:disable RSpec/ExampleLength
200203
token = Html2rss::Web::Auth.generate_feed_token('admin', feed_url, strategy: 'ssrf_filter')
201204

202-
allow(Html2rss::Web::AutoSource).to receive(:generate_feed_result).and_return(json_result)
205+
allow(Html2rss::Web::Feeds::Service).to receive(:call).and_return(feed_result)
206+
allow(Html2rss::Web::Feeds::JsonRenderer).to receive(:call)
207+
.and_return('{"version":"https://jsonfeed.org/version/1.1","items":[]}')
203208

204209
get "/api/v1/feeds/#{token}", {}, { 'HTTP_ACCEPT' => 'application/feed+json' }
205210

@@ -209,21 +214,23 @@ def json_result
209214
expect(last_response.headers['Vary']).to include('Accept')
210215
end
211216

212-
it 'prefers xml when Accept quality outranks json', :aggregate_failures do
217+
it 'prefers xml when Accept quality outranks json', :aggregate_failures do # rubocop:disable RSpec/ExampleLength
213218
token = Html2rss::Web::Auth.generate_feed_token('admin', feed_url, strategy: 'ssrf_filter')
214219

215-
allow(Html2rss::Web::AutoSource).to receive(:generate_feed_result).and_return(rss_result)
220+
allow(Html2rss::Web::Feeds::Service).to receive(:call).and_return(feed_result)
221+
allow(Html2rss::Web::Feeds::RssRenderer).to receive(:call).and_return('<rss version="2.0"></rss>')
216222

217223
get "/api/v1/feeds/#{token}", {}, { 'HTTP_ACCEPT' => 'application/xml;q=1.0, application/feed+json;q=0.2' }
218224

219225
expect(last_response.status).to eq(200)
220226
expect(last_response.content_type).to include('application/xml')
221227
end
222228

223-
it 'ignores query param strategy overrides', :aggregate_failures, openapi: false do
229+
it 'ignores query param strategy overrides', :aggregate_failures, openapi: false do # rubocop:disable RSpec/ExampleLength
224230
token = Html2rss::Web::Auth.generate_feed_token('admin', feed_url, strategy: 'ssrf_filter')
225231

226-
allow(Html2rss::Web::AutoSource).to receive(:generate_feed_result).and_return(rss_result)
232+
allow(Html2rss::Web::Feeds::Service).to receive(:call).and_return(feed_result)
233+
allow(Html2rss::Web::Feeds::RssRenderer).to receive(:call).and_return('<rss version="2.0"></rss>')
227234

228235
get "/api/v1/feeds/#{token}", { strategy: 'bad' }, { 'HTTP_ACCEPT' => 'application/xml' }
229236

spec/html2rss/web/app_integration_spec.rb

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,13 @@
3434
let(:auth_headers) { json_headers.merge('HTTP_AUTHORIZATION' => "Bearer #{account[:token]}") }
3535
let(:json_body) { JSON.parse(last_response.body) }
3636
let(:json_feed_error) { JSON.parse(last_response.body).slice('version', 'title') }
37-
let(:feed_result) { Html2rss::Web::FeedRenderResult.new(body: '<rss version="2.0"></rss>', ttl_seconds: 600) }
38-
let(:json_feed_result) do
39-
Html2rss::Web::FeedRenderResult.new(
40-
body: '{"version":"https://jsonfeed.org/version/1.1","items":[]}',
41-
ttl_seconds: 600
37+
let(:feed_result) do
38+
Html2rss::Web::Feeds::Result.new(
39+
status: :ok,
40+
payload: nil,
41+
message: nil,
42+
ttl_seconds: 600,
43+
cache_key: 'feed_result:test'
4244
)
4345
end
4446

@@ -63,12 +65,10 @@
6365
allow(Html2rss::Web::AccountManager).to receive(:get_account_by_username).and_return(account)
6466
allow(Html2rss::Web::UrlValidator).to receive(:url_allowed?).and_return(true)
6567
allow(Html2rss::Web::AutoSource).to receive(:enabled?).and_return(true)
66-
allow(Html2rss::Web::AutoSource).to receive(:generate_feed_result)
67-
.with(anything, anything, format: Html2rss::Web::FeedResponseFormat::RSS)
68-
.and_return(feed_result)
69-
allow(Html2rss::Web::AutoSource).to receive(:generate_feed_result)
70-
.with(anything, anything, format: Html2rss::Web::FeedResponseFormat::JSON_FEED)
71-
.and_return(json_feed_result)
68+
allow(Html2rss::Web::Feeds::Service).to receive(:call).and_return(feed_result)
69+
allow(Html2rss::Web::Feeds::RssRenderer).to receive(:call).and_return('<rss version="2.0"></rss>')
70+
allow(Html2rss::Web::Feeds::JsonRenderer).to receive(:call)
71+
.and_return('{"version":"https://jsonfeed.org/version/1.1","items":[]}')
7272
end
7373

7474
describe 'GET /api/v1/feeds/:token' do # rubocop:disable RSpec/MultipleMemoizedHelpers

0 commit comments

Comments
 (0)