From 17b6446bf3e2acb36994a1c6864dfe52501749dd Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sat, 14 Mar 2026 15:31:17 +0100 Subject: [PATCH 01/11] test: lock feed pipeline behavior --- app/feed_request_handler.rb | 7 ++- spec/html2rss/web/app_integration_spec.rb | 25 +++++++++ spec/html2rss/web/app_spec.rb | 10 ++++ spec/html2rss/web/feed_accept_header_spec.rb | 40 +++++++++++++++ .../html2rss/web/feed_request_handler_spec.rb | 51 +++++++++++++++++++ 5 files changed, 129 insertions(+), 4 deletions(-) create mode 100644 spec/html2rss/web/feed_accept_header_spec.rb create mode 100644 spec/html2rss/web/feed_request_handler_spec.rb diff --git a/app/feed_request_handler.rb b/app/feed_request_handler.rb index 601b69d5..504ca665 100644 --- a/app/feed_request_handler.rb +++ b/app/feed_request_handler.rb @@ -42,7 +42,7 @@ def feed_ttl_seconds(feed_name) # @return [String] def feed_content(feed_name, params, format, ttl_seconds, async_refresh) FeedRuntime.read( - key: feed_cache_key(feed_name, params, format), + key: feed_cache_key(feed_name, params), ttl_seconds: ttl_seconds, async_refresh: async_refresh ) do @@ -52,12 +52,11 @@ def feed_content(feed_name, params, format, ttl_seconds, async_refresh) # @param feed_name [String] # @param params [Hash] - # @param format [Symbol] # @return [String] - def feed_cache_key(feed_name, params, format) + def feed_cache_key(feed_name, params) normalized_params = params.to_h.sort_by { |key, _| key.to_s } digest = Digest::SHA256.hexdigest(Marshal.dump(normalized_params)) - "local_feed:#{feed_name}:#{format}:#{digest}" + "local_feed:#{feed_name}:#{digest}" end end end diff --git a/spec/html2rss/web/app_integration_spec.rb b/spec/html2rss/web/app_integration_spec.rb index 06c6cb62..caf4bfef 100644 --- a/spec/html2rss/web/app_integration_spec.rb +++ b/spec/html2rss/web/app_integration_spec.rb @@ -123,6 +123,31 @@ expect(last_response.headers['Content-Type']).to eq('application/xml') end + it 'treats wildcard Accept as rss unless json is more specific', :aggregate_failures do + header 'Accept', '*/*' + get "/api/v1/feeds/#{feed_token}" + + expect(last_response.status).to eq(200) + expect(last_response.headers['Content-Type']).to eq('application/xml') + end + + it 'ignores q=0 json feed media types during negotiation', :aggregate_failures do + header 'Accept', 'application/feed+json;q=0, application/xml;q=0.4' + get "/api/v1/feeds/#{feed_token}" + + expect(last_response.status).to eq(200) + expect(last_response.headers['Content-Type']).to eq('application/xml') + end + + it 'serves HEAD requests for token feeds with negotiated headers only', :aggregate_failures do + head "/api/v1/feeds/#{feed_token}", {}, { 'HTTP_ACCEPT' => 'application/feed+json' } + + expect(last_response.status).to eq(200) + expect(last_response.headers['Content-Type']).to eq('application/feed+json') + expect(last_response.headers['Cache-Control']).to include('max-age=600') + expect(last_response.body).to eq('') + end + it 'ignores query param strategy overrides', :aggregate_failures do header 'Accept', 'application/xml' get "/api/v1/feeds/#{feed_token}", { 'strategy' => 'invalid' } diff --git a/spec/html2rss/web/app_spec.rb b/spec/html2rss/web/app_spec.rb index fe20d5fb..95d971dc 100644 --- a/spec/html2rss/web/app_spec.rb +++ b/spec/html2rss/web/app_spec.rb @@ -61,6 +61,16 @@ def app = described_class ) end + it 'serves HEAD requests for legacy feed routes with negotiated headers only', :aggregate_failures do # rubocop:disable RSpec/ExampleLength + stub_legacy_feed('') + head '/legacy' + + expect(last_response.status).to eq(200) + expect(last_response.headers['Content-Type']).to eq('application/xml') + expect(last_response.headers['Cache-Control']).to include('max-age=10800') + expect(last_response.body).to eq('') + end + it 'coerces string ttl values before cache expiry math', :aggregate_failures do allow(Html2rss::Web::Feeds).to receive(:generate_feed).and_return('') allow(Html2rss::Web::LocalConfig).to receive(:find).and_return({ channel: { ttl: '180' } }) diff --git a/spec/html2rss/web/feed_accept_header_spec.rb b/spec/html2rss/web/feed_accept_header_spec.rb new file mode 100644 index 00000000..48bb76fb --- /dev/null +++ b/spec/html2rss/web/feed_accept_header_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../../../app' + +RSpec.describe Html2rss::Web::FeedAcceptHeader do + describe '.preferred_format' do + subject(:preferred_format) do + described_class.preferred_format( + accept_header, + json_media_types: Html2rss::Web::FeedResponseFormat::JSON_MEDIA_TYPES, + rss_media_types: Html2rss::Web::FeedResponseFormat::RSS_MEDIA_TYPES + ) + end + + context 'when wildcard media types are present' do + let(:accept_header) { 'application/feed+json;q=0.8, */*;q=0.2' } + + it 'prefers the more specific json feed match' do + expect(preferred_format).to eq(Html2rss::Web::FeedResponseFormat::JSON_FEED) + end + end + + context 'when json feed is explicitly refused' do + let(:accept_header) { 'application/feed+json;q=0, application/xml;q=0.4' } + + it 'falls back to rss negotiation' do + expect(preferred_format).to be_nil + end + end + + context 'when rss is explicitly refused' do + let(:accept_header) { 'application/xml;q=0, application/feed+json;q=0.4' } + + it 'returns json feed' do + expect(preferred_format).to eq(Html2rss::Web::FeedResponseFormat::JSON_FEED) + end + end + end +end diff --git a/spec/html2rss/web/feed_request_handler_spec.rb b/spec/html2rss/web/feed_request_handler_spec.rb new file mode 100644 index 00000000..04b37b98 --- /dev/null +++ b/spec/html2rss/web/feed_request_handler_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../../../app' + +RSpec.describe Html2rss::Web::FeedRequestHandler do + describe '.call' do + let(:feed_name) { 'legacy' } + let(:params) { { 'page' => '1', 'sort' => 'recent' } } + + before do + allow(Html2rss::Web::LocalConfig).to receive(:find).with(feed_name).and_return({ channel: { ttl: 15 } }) + allow(Html2rss::Web::FeedRuntime).to receive(:read).and_yield + allow(Html2rss::Web::Feeds).to receive(:generate_feed).and_return('') + end + + def capture_runtime_reads + reads = [] + + allow(Html2rss::Web::FeedRuntime).to receive(:read) do |key:, ttl_seconds:, async_refresh:, &block| + reads << { key:, ttl_seconds:, async_refresh: } + block.call + end + + reads + end + + def exercise_both_formats(feed_name:, params:) + described_class.call(feed_name:, params:, format: Html2rss::Web::FeedResponseFormat::RSS) + described_class.call(feed_name:, params:, format: Html2rss::Web::FeedResponseFormat::JSON_FEED) + end + + it 'uses the same cache key for rss and json representations', :aggregate_failures do + reads = capture_runtime_reads + + exercise_both_formats(feed_name:, params:) + + expect(reads.map { |read| read[:key] }.uniq).to contain_exactly(reads.first[:key]) + expect(reads.map { |read| read[:ttl_seconds] }).to all(eq(900)) + expect(reads.map { |read| read[:async_refresh] }).to all(be(false)) + end + + it 'keeps ttl identical across representations', :aggregate_failures do + reads = capture_runtime_reads + + exercise_both_formats(feed_name:, params:) + + expect(reads.map { |read| read[:ttl_seconds] }.uniq).to eq([900]) + end + end +end From eddaa09762e020ca831dd5a4358e468d3d9bc809 Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sat, 14 Mar 2026 15:40:57 +0100 Subject: [PATCH 02/11] feat!: add namespaced feed pipeline primitives --- app.rb | 28 +--- app/feeds/cache.rb | 82 +++++++++++ app/feeds/json_renderer.rb | 87 +++++++++++ app/feeds/request.rb | 11 ++ app/feeds/request_parser.rb | 33 +++++ app/feeds/resolved_source.rb | 11 ++ app/feeds/resolver.rb | 135 ++++++++++++++++++ app/feeds/response_format.rb | 36 +++++ app/feeds/result.rb | 11 ++ app/feeds/rss_renderer.rb | 47 ++++++ app/feeds/service.rb | 82 +++++++++++ .../html2rss/web/feeds/request_parser_spec.rb | 38 +++++ spec/html2rss/web/feeds/resolver_spec.rb | 89 ++++++++++++ 13 files changed, 663 insertions(+), 27 deletions(-) create mode 100644 app/feeds/cache.rb create mode 100644 app/feeds/json_renderer.rb create mode 100644 app/feeds/request.rb create mode 100644 app/feeds/request_parser.rb create mode 100644 app/feeds/resolved_source.rb create mode 100644 app/feeds/resolver.rb create mode 100644 app/feeds/response_format.rb create mode 100644 app/feeds/result.rb create mode 100644 app/feeds/rss_renderer.rb create mode 100644 app/feeds/service.rb create mode 100644 spec/html2rss/web/feeds/request_parser_spec.rb create mode 100644 spec/html2rss/web/feeds/resolver_spec.rb diff --git a/app.rb b/app.rb index ba1a9f03..de1ded84 100644 --- a/app.rb +++ b/app.rb @@ -6,33 +6,7 @@ require 'base64' require 'html2rss' -require_relative 'app/environment_validator' -require_relative 'app/auth' -require_relative 'app/auto_source' -require_relative 'app/feeds' -require_relative 'app/local_config' -require_relative 'app/flags' -require_relative 'app/cache_ttl' -require_relative 'app/exceptions' -require_relative 'app/xml_builder' -require_relative 'app/json_feed_builder' -require_relative 'app/feed_response_format' -require_relative 'app/request_target' -require_relative 'app/feed_render_result' -require_relative 'app/error_responder' -require_relative 'app/security_logger' -require_relative 'app/observability' -require_relative 'app/app_context' -require_relative 'app/request_context_middleware' -require_relative 'app/api/v1/feeds' -require_relative 'app/api/v1/health' -require_relative 'app/api/v1/strategies' -require_relative 'app/ssrf_filter_strategy' -require_relative 'app/http_cache' -require_relative 'app/feed_request_handler' -require_relative 'app/feed_route_handler' -require_relative 'app/routes/api_v1' -require_relative 'app/routes/static' +Dir[File.join(__dir__, 'app/**/*.rb')].each { |file| require file } module Html2rss module Web diff --git a/app/feeds/cache.rb b/app/feeds/cache.rb new file mode 100644 index 00000000..0119b875 --- /dev/null +++ b/app/feeds/cache.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +require 'digest' +require 'time' + +require_relative '../cache_ttl' +require_relative '../security_logger' + +module Html2rss + module Web + module Feeds + ## + # Small synchronous cache for canonical feed results. + module Cache + Entry = Data.define(:result, :expires_at) + + class << self + # @param key [String] + # @param ttl_seconds [Integer] + # @yieldreturn [Html2rss::Web::Feeds::Result] + # @return [Html2rss::Web::Feeds::Result] + def fetch(key, ttl_seconds:) + entry = read_entry(key) + return entry.result if fresh?(entry) + + result = yield + write_entry(key, ttl_seconds, result) + result + end + + # @param reason [String] + # @return [nil] + def clear!(reason: 'manual') + @entries = {} # rubocop:disable ThreadSafety/ClassInstanceVariable + SecurityLogger.log_cache_lifecycle('feeds_cache', 'clear', reason: reason) + nil + end + + private + + # @param key [String] + # @return [Entry, nil] + def read_entry(key) + entries[key] + end + + # @param entry [Entry, nil] + # @return [Boolean] + def fresh?(entry) + entry && Time.now.utc < entry.expires_at + end + + # @param key [String] + # @param ttl_seconds [Integer] + # @param result [Html2rss::Web::Feeds::Result] + # @return [void] + def write_entry(key, ttl_seconds, result) + entries[key] = Entry.new(result: result, expires_at: Time.now.utc + normalize_ttl(ttl_seconds)) + SecurityLogger.log_cache_lifecycle('feeds_cache', 'write', key_hash: key_hash(key)) + end + + # @return [Hash{String=>Entry}] + def entries + @entries ||= {} # rubocop:disable ThreadSafety/ClassInstanceVariable + end + + # @param ttl_seconds [Integer] + # @return [Integer] + def normalize_ttl(ttl_seconds) + ttl_seconds.to_i.positive? ? ttl_seconds.to_i : CacheTtl::DEFAULT_SECONDS + end + + # @param key [String] + # @return [String] + def key_hash(key) + Digest::SHA256.hexdigest(key)[0..11] + end + end + end + end + end +end diff --git a/app/feeds/json_renderer.rb b/app/feeds/json_renderer.rb new file mode 100644 index 00000000..e8465bf7 --- /dev/null +++ b/app/feeds/json_renderer.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +require 'json' +require 'time' + +require_relative '../exceptions' +require_relative '../json_feed_builder' + +module Html2rss + module Web + module Feeds + ## + # Renders JSON Feed output from shared feed results. + module JsonRenderer + VERSION = 'https://jsonfeed.org/version/1.1' + + class << self + # @param result [Html2rss::Web::Feeds::Result] + # @return [String] + def call(result) + case result.status + when :ok + JSON.generate(payload_for(result.payload.fetch(:feed))) + when :empty + empty_feed(result) + else + error_feed(result) + end + end + + private + + # @param result [Html2rss::Web::Feeds::Result] + # @return [String] + def empty_feed(result) + JsonFeedBuilder.build_empty_feed_warning( + url: result.payload.fetch(:url), + strategy: result.payload.fetch(:strategy), + site_title: result.payload.fetch(:feed).channel.title + ) + end + + # @param result [Html2rss::Web::Feeds::Result] + # @return [String] + def error_feed(result) + JsonFeedBuilder.build_error_feed(message: result.message || HttpError::DEFAULT_MESSAGE) + end + + # @param feed [RSS::Rss] + # @return [Hash{Symbol=>Object}] + def payload_for(feed) + { + version: VERSION, + title: feed.channel.title, + home_page_url: feed.channel.link, + description: feed.channel.description, + items: feed.items.map { |item| item_payload(item) } + }.compact + end + + # @param item [Object] + # @return [Hash{Symbol=>Object}] + def item_payload(item) + { + id: item.respond_to?(:guid) && item.guid ? item.guid.content : (item.link || item.title), + url: item.link, + title: item.title, + content_text: item.description, + date_published: published_at(item) + }.compact + end + + # @param item [Object] + # @return [String, nil] + def published_at(item) + value = item.respond_to?(:pubDate) ? item.pubDate : nil + return value.iso8601 if value.respond_to?(:iso8601) + + Time.parse(value.to_s).utc.iso8601 if value + rescue ArgumentError + nil + end + end + end + end + end +end diff --git a/app/feeds/request.rb b/app/feeds/request.rb new file mode 100644 index 00000000..3cc00316 --- /dev/null +++ b/app/feeds/request.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Html2rss + module Web + module Feeds + ## + # Request-edge contract for feed rendering. + Request = Data.define(:target_kind, :representation, :feed_name, :token, :params) + end + end +end diff --git a/app/feeds/request_parser.rb b/app/feeds/request_parser.rb new file mode 100644 index 00000000..2ba6f0b4 --- /dev/null +++ b/app/feeds/request_parser.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require_relative 'request' +require_relative 'response_format' + +module Html2rss + module Web + module Feeds + ## + # Parses route inputs into shared feed request contracts. + module RequestParser + class << self + # @param request [Rack::Request] + # @param target_kind [Symbol] + # @param identifier [String] + # @return [Html2rss::Web::Feeds::Request] + def call(request:, target_kind:, identifier:) + representation = ResponseFormat.for_request(request) + normalized_identifier = ResponseFormat.strip_known_extension(identifier) + + Request.new( + target_kind: target_kind, + representation: representation, + feed_name: target_kind == :static ? normalized_identifier : nil, + token: target_kind == :token ? normalized_identifier : nil, + params: request.params.to_h + ) + end + end + end + end + end +end diff --git a/app/feeds/resolved_source.rb b/app/feeds/resolved_source.rb new file mode 100644 index 00000000..a7715d10 --- /dev/null +++ b/app/feeds/resolved_source.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Html2rss + module Web + module Feeds + ## + # Normalized source inputs for shared feed generation. + ResolvedSource = Data.define(:source_kind, :cache_identity, :generator_input, :ttl_seconds) + end + end +end diff --git a/app/feeds/resolver.rb b/app/feeds/resolver.rb new file mode 100644 index 00000000..b1df2617 --- /dev/null +++ b/app/feeds/resolver.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +require 'digest' + +require_relative '../account_manager' +require_relative '../auth' +require_relative '../auto_source' +require_relative '../cache_ttl' +require_relative '../exceptions' +require_relative '../local_config' +require_relative '../url_validator' +require_relative '../api/v1/contract' +require_relative 'resolved_source' + +module Html2rss + module Web + module Feeds + ## + # Resolves static and token-backed requests into shared generator inputs. + module Resolver + class << self + # @param feed_request [Html2rss::Web::Feeds::Request] + # @return [Html2rss::Web::Feeds::ResolvedSource] + def call(feed_request) + case feed_request.target_kind + when :static + resolve_static(feed_request) + when :token + resolve_token(feed_request) + else + raise BadRequestError, "Unsupported feed target: #{feed_request.target_kind}" + end + end + + private + + # @param feed_request [Html2rss::Web::Feeds::Request] + # @return [Html2rss::Web::Feeds::ResolvedSource] + def resolve_static(feed_request) + config = LocalConfig.find(feed_request.feed_name) + config[:params] = (config[:params] || {}).merge(feed_request.params) if feed_request.params.any? + config[:strategy] ||= Html2rss::RequestService.default_strategy_name + + ResolvedSource.new( + source_kind: :static, + cache_identity: static_cache_identity(feed_request.feed_name, feed_request.params), + generator_input: config, + ttl_seconds: CacheTtl.seconds_from_minutes(config.dig(:channel, :ttl)) + ) + end + + # @param feed_request [Html2rss::Web::Feeds::Request] + # @return [Html2rss::Web::Feeds::ResolvedSource] + def resolve_token(feed_request) + feed_token = validated_feed_token(feed_request.token) + strategy = resolved_strategy(feed_token) + generator_input = token_generator_input(feed_token.url, strategy) + + ResolvedSource.new( + source_kind: :token, + cache_identity: token_cache_identity(feed_request.token), + generator_input: generator_input, + ttl_seconds: CacheTtl.seconds_from_minutes(generator_input.dig(:channel, :ttl), default: 300) + ) + end + + # @param feed_name [String] + # @param params [Hash{Object=>Object}] + # @return [String] + def static_cache_identity(feed_name, params) + normalized_params = params.to_h.sort_by { |key, _| key.to_s } + digest = Digest::SHA256.hexdigest(Marshal.dump(normalized_params)) + "static:#{feed_name}:#{digest}" + end + + # @param token [String] + # @return [String] + def token_cache_identity(token) + "token:#{Digest::SHA256.hexdigest(token.to_s)}" + end + + # @param token [String] + # @return [Html2rss::Web::FeedToken] + def validated_feed_token(token) + feed_token = Auth.validate_and_decode_feed_token(token) + raise UnauthorizedError, 'Invalid token' unless feed_token + + account = AccountManager.get_account_by_username(feed_token.username) + raise UnauthorizedError, 'Account not found' unless account + + ensure_token_access!(account, feed_token.url) + ensure_auto_source_enabled! + feed_token + end + + # @param account [Hash{Symbol=>Object}] + # @param url [String] + # @return [void] + def ensure_token_access!(account, url) + raise ForbiddenError, 'Access Denied' unless UrlValidator.url_allowed?(account, url) + end + + # @return [void] + def ensure_auto_source_enabled! + raise ForbiddenError, Api::V1::Contract::MESSAGES[:auto_source_disabled] unless AutoSource.enabled? + end + + # @param feed_token [Html2rss::Web::FeedToken] + # @return [String] + def resolved_strategy(feed_token) + strategy = feed_token.strategy.to_s.strip + strategy = Html2rss::RequestService.default_strategy_name.to_s if strategy.empty? + supported = Html2rss::RequestService.strategy_names.map(&:to_s) + raise BadRequestError, 'Unsupported strategy' unless supported.include?(strategy) + + strategy + end + + # @param url [String] + # @param strategy [String] + # @return [Hash{Symbol=>Object}] + def token_generator_input(url, strategy) + LocalConfig.global + .slice(:stylesheets, :headers) + .merge( + strategy: strategy.to_sym, + channel: { url: url }, + auto_source: {} + ) + end + end + end + end + end +end diff --git a/app/feeds/response_format.rb b/app/feeds/response_format.rb new file mode 100644 index 00000000..d162a402 --- /dev/null +++ b/app/feeds/response_format.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require_relative '../feed_response_format' + +module Html2rss + module Web + module Feeds + ## + # Feed representation helpers scoped to the new feed pipeline. + module ResponseFormat + JSON_FEED = Html2rss::Web::FeedResponseFormat::JSON_FEED + RSS = Html2rss::Web::FeedResponseFormat::RSS + + class << self + # @param request [Rack::Request] + # @return [Symbol] + def for_request(request) + Html2rss::Web::FeedResponseFormat.for_request(request) + end + + # @param value [String] + # @return [String] + def strip_known_extension(value) + Html2rss::Web::FeedResponseFormat.strip_known_extension(value) + end + + # @param format [Symbol] + # @return [String] + def content_type(format) + Html2rss::Web::FeedResponseFormat.content_type(format) + end + end + end + end + end +end diff --git a/app/feeds/result.rb b/app/feeds/result.rb new file mode 100644 index 00000000..d5b5d2d5 --- /dev/null +++ b/app/feeds/result.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Html2rss + module Web + module Feeds + ## + # Shared feed-serving result wrapper. + Result = Data.define(:status, :payload, :message, :ttl_seconds, :cache_key) + end + end +end diff --git a/app/feeds/rss_renderer.rb b/app/feeds/rss_renderer.rb new file mode 100644 index 00000000..b022b05c --- /dev/null +++ b/app/feeds/rss_renderer.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require_relative '../exceptions' +require_relative '../xml_builder' + +module Html2rss + module Web + module Feeds + ## + # Renders RSS bodies from shared feed results. + module RssRenderer + class << self + # @param result [Html2rss::Web::Feeds::Result] + # @return [String] + def call(result) + case result.status + when :ok + result.payload.fetch(:feed).to_s + when :empty + empty_feed(result) + else + error_feed(result) + end + end + + private + + # @param result [Html2rss::Web::Feeds::Result] + # @return [String] + def empty_feed(result) + XmlBuilder.build_empty_feed_warning( + url: result.payload.fetch(:url), + strategy: result.payload.fetch(:strategy), + site_title: result.payload.fetch(:feed).channel.title + ) + end + + # @param result [Html2rss::Web::Feeds::Result] + # @return [String] + def error_feed(result) + XmlBuilder.build_error_feed(message: result.message || HttpError::DEFAULT_MESSAGE) + end + end + end + end + end +end diff --git a/app/feeds/service.rb b/app/feeds/service.rb new file mode 100644 index 00000000..ba830df3 --- /dev/null +++ b/app/feeds/service.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +require_relative 'cache' +require_relative 'result' + +module Html2rss + module Web + module Feeds + ## + # Shared synchronous feed service around the html2rss gem. + module Service + class << self + # @param resolved_source [Html2rss::Web::Feeds::ResolvedSource] + # @return [Html2rss::Web::Feeds::Result] + def call(resolved_source) + cache_key = "feed_result:#{resolved_source.cache_identity}" + + Cache.fetch(cache_key, ttl_seconds: resolved_source.ttl_seconds) do + build_result(resolved_source, cache_key) + end + end + + private + + # @param resolved_source [Html2rss::Web::Feeds::ResolvedSource] + # @param cache_key [String] + # @return [Html2rss::Web::Feeds::Result] + def build_result(resolved_source, cache_key) + feed = Html2rss.feed(resolved_source.generator_input) + + Result.new( + status: result_status(feed), + payload: payload_for(feed, resolved_source), + message: nil, + ttl_seconds: resolved_source.ttl_seconds, + cache_key: cache_key + ) + rescue StandardError => error + error_result(error, resolved_source, cache_key) + end + + # @param feed [Object] + # @return [Boolean] + def feed_has_items?(feed) + feed.respond_to?(:items) && !feed.items.empty? + end + + # @param feed [Object] + # @return [Symbol] + def result_status(feed) + feed_has_items?(feed) ? :ok : :empty + end + + # @param feed [Object] + # @param resolved_source [Html2rss::Web::Feeds::ResolvedSource] + # @return [Hash{Symbol=>Object}] + def payload_for(feed, resolved_source) + { + feed: feed, + url: resolved_source.generator_input.dig(:channel, :url), + strategy: resolved_source.generator_input[:strategy].to_s + } + end + + # @param error [StandardError] + # @param resolved_source [Html2rss::Web::Feeds::ResolvedSource] + # @param cache_key [String] + # @return [Html2rss::Web::Feeds::Result] + def error_result(error, resolved_source, cache_key) + Result.new( + status: :error, + payload: nil, + message: error.message, + ttl_seconds: resolved_source.ttl_seconds, + cache_key: cache_key + ) + end + end + end + end + end +end diff --git a/spec/html2rss/web/feeds/request_parser_spec.rb b/spec/html2rss/web/feeds/request_parser_spec.rb new file mode 100644 index 00000000..202c6356 --- /dev/null +++ b/spec/html2rss/web/feeds/request_parser_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../../../../app' + +RSpec.describe Html2rss::Web::Feeds::RequestParser do + let(:request) { instance_double(Rack::Request, params: { 'page' => '2' }) } + + before do + allow(Html2rss::Web::Feeds::ResponseFormat).to receive(:for_request).with(request).and_return( + Html2rss::Web::Feeds::ResponseFormat::JSON_FEED + ) + allow(Html2rss::Web::Feeds::ResponseFormat).to receive(:strip_known_extension) + .with('legacy.json').and_return('legacy') + allow(Html2rss::Web::Feeds::ResponseFormat).to receive(:strip_known_extension) + .with('token.json').and_return('token') + end + + def request_tuple(parsed) + [parsed.target_kind, parsed.representation, parsed.feed_name, parsed.token, parsed.params] + end + + it 'builds a static request with normalized feed name', :aggregate_failures do + parsed = described_class.call(request:, target_kind: :static, identifier: 'legacy.json') + + expect(request_tuple(parsed)).to eq( + [:static, Html2rss::Web::Feeds::ResponseFormat::JSON_FEED, 'legacy', nil, { 'page' => '2' }] + ) + end + + it 'builds a token request with normalized token', :aggregate_failures do + parsed = described_class.call(request:, target_kind: :token, identifier: 'token.json') + + expect(request_tuple(parsed)).to eq( + [:token, Html2rss::Web::Feeds::ResponseFormat::JSON_FEED, nil, 'token', { 'page' => '2' }] + ) + end +end diff --git a/spec/html2rss/web/feeds/resolver_spec.rb b/spec/html2rss/web/feeds/resolver_spec.rb new file mode 100644 index 00000000..fb002a1a --- /dev/null +++ b/spec/html2rss/web/feeds/resolver_spec.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../../../../app' + +RSpec.describe Html2rss::Web::Feeds::Resolver do + describe '.call' do + def resolved_tuple(resolved) + [resolved.source_kind, resolved.cache_identity, resolved.ttl_seconds, resolved.generator_input] + end + + context 'with a static request' do + let(:feed_request) do + Html2rss::Web::Feeds::Request.new( + target_kind: :static, + representation: Html2rss::Web::Feeds::ResponseFormat::RSS, + feed_name: 'legacy', + token: nil, + params: { 'page' => '3' } + ) + end + + before do + allow(Html2rss::Web::LocalConfig).to receive(:find).with('legacy').and_return( + { + channel: { ttl: 15, url: 'https://example.com/feed' }, + params: { 'existing' => '1' } + } + ) + allow(Html2rss::RequestService).to receive(:default_strategy_name).and_return(:ssrf_filter) + end + + it 'normalizes the static source into shared generator input', :aggregate_failures do # rubocop:disable RSpec/ExampleLength + resolved = described_class.call(feed_request) + + expect(resolved_tuple(resolved)).to match( + [ + :static, + start_with('static:legacy:'), + 900, + include(params: { 'existing' => '1', 'page' => '3' }, strategy: :ssrf_filter) + ] + ) + end + end + + context 'with a token request' do + let(:feed_request) do + Html2rss::Web::Feeds::Request.new( + target_kind: :token, + representation: Html2rss::Web::Feeds::ResponseFormat::RSS, + feed_name: nil, + token: 'public-token', + params: {} + ) + end + let(:feed_token) do + instance_double( + Html2rss::Web::FeedToken, + username: 'admin', + url: 'https://example.com/private', + strategy: 'ssrf_filter' + ) + end + + before do + allow(Html2rss::Web::Auth).to receive(:validate_and_decode_feed_token) + .with('public-token').and_return(feed_token) + allow(Html2rss::Web::AccountManager).to receive(:get_account_by_username) + .with('admin').and_return({ username: 'admin' }) + allow(Html2rss::Web::UrlValidator).to receive(:url_allowed?) + .with({ username: 'admin' }, 'https://example.com/private').and_return(true) + allow(Html2rss::Web::AutoSource).to receive(:enabled?).and_return(true) + allow(Html2rss::RequestService).to receive(:strategy_names).and_return([:ssrf_filter]) + allow(Html2rss::Web::LocalConfig).to receive(:global) + .and_return({ headers: { 'User-Agent' => 'html2rss-web' } }) + end + + it 'normalizes the token source into shared generator input', :aggregate_failures do + resolved = described_class.call(feed_request) + + expect(resolved_tuple(resolved)).to match( + [:token, start_with('token:'), 300, + include(strategy: :ssrf_filter, channel: { url: 'https://example.com/private' }, auto_source: {})] + ) + end + end + end +end From b5d554c8f3048326a735ca4d686445e7b7c4183f Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sat, 14 Mar 2026 15:46:58 +0100 Subject: [PATCH 03/11] feat!: route feeds through shared synchronous service --- app/api/v1/feeds/show_feed.rb | 116 ++++++++-------------- app/feed_route_handler.rb | 36 ++++--- spec/html2rss/web/api/v1_spec.rb | 31 +++--- spec/html2rss/web/app_integration_spec.rb | 22 ++-- 4 files changed, 91 insertions(+), 114 deletions(-) diff --git a/app/api/v1/feeds/show_feed.rb b/app/api/v1/feeds/show_feed.rb index 2b57ef62..4cad4e5c 100644 --- a/app/api/v1/feeds/show_feed.rb +++ b/app/api/v1/feeds/show_feed.rb @@ -2,13 +2,15 @@ require_relative '../../../account_manager' require_relative '../../../auth' -require_relative '../../../auto_source' require_relative '../../../exceptions' require_relative '../../../feed_response_format' -require_relative '../../../feed_generator' +require_relative '../../../feeds/json_renderer' +require_relative '../../../feeds/request_parser' +require_relative '../../../feeds/resolver' +require_relative '../../../feeds/rss_renderer' +require_relative '../../../feeds/service' require_relative '../../../http_cache' require_relative '../../../observability' -require_relative '../../../url_validator' module Html2rss module Web @@ -28,12 +30,10 @@ class << self # @param token [String] signed public feed token. # @return [String] serialized feed response body. def call(request, token) - format = FeedResponseFormat.for_request(request) - normalized_token = FeedResponseFormat.strip_known_extension(token) - feed_token, strategy = resolve_authorized_feed(normalized_token) - rendered = render_generated_feed(request, feed_token.url, strategy, format) - emit_render_success(strategy, feed_token.url) - rendered + feed_request, resolved_source, result = feed_pipeline(request, token) + configure_response(request, feed_request.representation, result.ttl_seconds) + emit_success_from(resolved_source) + render_result(result, feed_request.representation) rescue StandardError => error emit_render_failure(error) raise @@ -41,84 +41,50 @@ def call(request, token) private - # @return [void] - def ensure_auto_source_enabled! - raise ForbiddenError, Contract::MESSAGES[:auto_source_disabled] unless AutoSource.enabled? - end - + # @param request [Rack::Request] # @param token [String] - # @return [Html2rss::Web::FeedToken] - def validated_token_for(token) - feed_token = Auth.validate_and_decode_feed_token(token) - raise UnauthorizedError, 'Invalid token' unless feed_token - - feed_token - end - - # @param feed_token [Html2rss::Web::FeedToken] - # @return [Hash{Symbol=>Object}] account attributes. - def account_for(feed_token) - account = AccountManager.get_account_by_username(feed_token.username) - raise UnauthorizedError, 'Account not found' unless account - - account - end - - # @param account [Hash{Symbol=>Object}] - # @param url [String] - # @return [void] - def ensure_access!(account, url) - raise ForbiddenError, 'Access Denied' unless UrlValidator.url_allowed?(account, url) - end - - # @param feed_token [Html2rss::Web::FeedToken] - # @return [String] validated strategy identifier. - def resolve_token_strategy(feed_token) - strategy = feed_token.strategy.to_s.strip - strategy = default_strategy if strategy.empty? - - raise BadRequestError, 'Unsupported strategy' unless supported_strategies.include?(strategy) - - strategy - end - - # @return [Array] supported strategy identifiers. - def supported_strategies - Html2rss::RequestService.strategy_names.map(&:to_s) - end + # @return [Array<(Html2rss::Web::Feeds::Request, Html2rss::Web::Feeds::ResolvedSource, Html2rss::Web::Feeds::Result)>] + def feed_pipeline(request, token) + feed_request = ::Html2rss::Web::Feeds::RequestParser.call( + request: request, + target_kind: :token, + identifier: token + ) + resolved_source = ::Html2rss::Web::Feeds::Resolver.call(feed_request) + result = ::Html2rss::Web::Feeds::Service.call(resolved_source) + raise InternalServerError, result.message if result.status == :error - # @return [String] default strategy identifier. - def default_strategy - Html2rss::RequestService.default_strategy_name.to_s + [feed_request, resolved_source, result] end - # Builds HTTP response headers and returns XML body. - # # @param request [Rack::Request] - # @param url [String] - # @param strategy [String] # @param format [Symbol] - # @return [String] rendered feed body. - def render_generated_feed(request, url, strategy, format) - rendered_feed = AutoSource.generate_feed_result(url, strategy, format:) - + # @param ttl_seconds [Integer] + # @return [void] + def configure_response(request, format, ttl_seconds) request.response['Content-Type'] = FeedResponseFormat.content_type(format) - HttpCache.expires(request.response, rendered_feed.ttl_seconds, cache_control: 'public') + HttpCache.expires(request.response, ttl_seconds, cache_control: 'public') HttpCache.vary(request.response, 'Accept') + end - rendered_feed.body + # @param resolved_source [Html2rss::Web::Feeds::ResolvedSource] + # @return [void] + def emit_success_from(resolved_source) + emit_render_success( + resolved_source.generator_input[:strategy], + resolved_source.generator_input.dig(:channel, :url) + ) end - # @param token [String] - # @return [Array<(Html2rss::Web::FeedToken, String)>] - def resolve_authorized_feed(token) - feed_token = validated_token_for(token) - account = account_for(feed_token) - ensure_access!(account, feed_token.url) - ensure_auto_source_enabled! + # @param result [Html2rss::Web::Feeds::Result] + # @param format [Symbol] + # @return [String] + def render_result(result, format) + if format == ::Html2rss::Web::Feeds::ResponseFormat::JSON_FEED + return ::Html2rss::Web::Feeds::JsonRenderer.call(result) + end - strategy = resolve_token_strategy(feed_token) - [feed_token, strategy] + ::Html2rss::Web::Feeds::RssRenderer.call(result) end # @param strategy [String] diff --git a/app/feed_route_handler.rb b/app/feed_route_handler.rb index f937aa83..370ea693 100644 --- a/app/feed_route_handler.rb +++ b/app/feed_route_handler.rb @@ -1,6 +1,11 @@ # frozen_string_literal: true require_relative 'feed_response_format' +require_relative 'feeds/json_renderer' +require_relative 'feeds/request_parser' +require_relative 'feeds/resolver' +require_relative 'feeds/rss_renderer' +require_relative 'feeds/service' require_relative 'http_cache' module Html2rss @@ -14,11 +19,15 @@ class << self # @param feed_name [String] # @return [String] def call(context:, router:, feed_name:) - format = FeedResponseFormat.for_request(router) - content, ttl_seconds = fetch_feed_payload(context, router, feed_name, format) - emit_success(context, feed_name, router.params['strategy']) - configure_response(router, ttl_seconds, format) - content + feed_request = Feeds::RequestParser.call(request: router, target_kind: :static, identifier: feed_name) + resolved_source = Feeds::Resolver.call(feed_request) + result = Feeds::Service.call(resolved_source) + + raise InternalServerError, result.message if result.status == :error + + emit_success(context, feed_name, resolved_source.generator_input[:strategy]) + configure_response(router, result.ttl_seconds, feed_request.representation) + render_result(result, feed_request.representation) rescue StandardError => error emit_failure(context, feed_name, error) raise @@ -52,18 +61,13 @@ def emit_failure(context, feed_name, error) ) end - # @param context [Html2rss::Web::AppContext::Context] - # @param router [Roda::RodaRequest] - # @param feed_name [String] + # @param result [Html2rss::Web::Feeds::Result] # @param format [Symbol] - # @return [Array<(String, Integer)>] - def fetch_feed_payload(context, router, feed_name, format) - context.feed_request_handler.call( - feed_name: feed_name, - params: router.params, - format: format, - async_refresh: context.flags.async_feed_refresh_enabled? - ) + # @return [String] + def render_result(result, format) + return Feeds::JsonRenderer.call(result) if format == Feeds::ResponseFormat::JSON_FEED + + Feeds::RssRenderer.call(result) end # @param router [Roda::RodaRequest] diff --git a/spec/html2rss/web/api/v1_spec.rb b/spec/html2rss/web/api/v1_spec.rb index cea55477..f3df6a0d 100644 --- a/spec/html2rss/web/api/v1_spec.rb +++ b/spec/html2rss/web/api/v1_spec.rb @@ -9,12 +9,14 @@ def app = Html2rss::Web::App.freeze.app def json_feed_error = JSON.parse(last_response.body).slice('version', 'title') - def rss_result = Html2rss::Web::FeedRenderResult.new(body: '', ttl_seconds: 600) - def json_result - Html2rss::Web::FeedRenderResult.new( - body: '{"version":"https://jsonfeed.org/version/1.1","items":[]}', - ttl_seconds: 600 + def feed_result + Html2rss::Web::Feeds::Result.new( + status: :ok, + payload: nil, + message: nil, + ttl_seconds: 600, + cache_key: 'feed_result:test' ) end @@ -185,10 +187,11 @@ def json_result expect(last_response.body).to include('Account not found') end - it 'renders feed for a valid token', :aggregate_failures do + it 'renders feed for a valid token', :aggregate_failures do # rubocop:disable RSpec/ExampleLength token = Html2rss::Web::Auth.generate_feed_token('admin', feed_url, strategy: 'ssrf_filter') - allow(Html2rss::Web::AutoSource).to receive(:generate_feed_result).and_return(rss_result) + allow(Html2rss::Web::Feeds::Service).to receive(:call).and_return(feed_result) + allow(Html2rss::Web::Feeds::RssRenderer).to receive(:call).and_return('') get "/api/v1/feeds/#{token}.xml" @@ -199,7 +202,9 @@ def json_result it 'renders json feed for a valid token when requested through Accept', :aggregate_failures do # rubocop:disable RSpec/ExampleLength token = Html2rss::Web::Auth.generate_feed_token('admin', feed_url, strategy: 'ssrf_filter') - allow(Html2rss::Web::AutoSource).to receive(:generate_feed_result).and_return(json_result) + allow(Html2rss::Web::Feeds::Service).to receive(:call).and_return(feed_result) + allow(Html2rss::Web::Feeds::JsonRenderer).to receive(:call) + .and_return('{"version":"https://jsonfeed.org/version/1.1","items":[]}') get "/api/v1/feeds/#{token}", {}, { 'HTTP_ACCEPT' => 'application/feed+json' } @@ -209,10 +214,11 @@ def json_result expect(last_response.headers['Vary']).to include('Accept') end - it 'prefers xml when Accept quality outranks json', :aggregate_failures do + it 'prefers xml when Accept quality outranks json', :aggregate_failures do # rubocop:disable RSpec/ExampleLength token = Html2rss::Web::Auth.generate_feed_token('admin', feed_url, strategy: 'ssrf_filter') - allow(Html2rss::Web::AutoSource).to receive(:generate_feed_result).and_return(rss_result) + allow(Html2rss::Web::Feeds::Service).to receive(:call).and_return(feed_result) + allow(Html2rss::Web::Feeds::RssRenderer).to receive(:call).and_return('') get "/api/v1/feeds/#{token}", {}, { 'HTTP_ACCEPT' => 'application/xml;q=1.0, application/feed+json;q=0.2' } @@ -220,10 +226,11 @@ def json_result expect(last_response.content_type).to include('application/xml') end - it 'ignores query param strategy overrides', :aggregate_failures, openapi: false do + it 'ignores query param strategy overrides', :aggregate_failures, openapi: false do # rubocop:disable RSpec/ExampleLength token = Html2rss::Web::Auth.generate_feed_token('admin', feed_url, strategy: 'ssrf_filter') - allow(Html2rss::Web::AutoSource).to receive(:generate_feed_result).and_return(rss_result) + allow(Html2rss::Web::Feeds::Service).to receive(:call).and_return(feed_result) + allow(Html2rss::Web::Feeds::RssRenderer).to receive(:call).and_return('') get "/api/v1/feeds/#{token}", { strategy: 'bad' }, { 'HTTP_ACCEPT' => 'application/xml' } diff --git a/spec/html2rss/web/app_integration_spec.rb b/spec/html2rss/web/app_integration_spec.rb index caf4bfef..f1361a27 100644 --- a/spec/html2rss/web/app_integration_spec.rb +++ b/spec/html2rss/web/app_integration_spec.rb @@ -34,11 +34,13 @@ let(:auth_headers) { json_headers.merge('HTTP_AUTHORIZATION' => "Bearer #{account[:token]}") } let(:json_body) { JSON.parse(last_response.body) } let(:json_feed_error) { JSON.parse(last_response.body).slice('version', 'title') } - let(:feed_result) { Html2rss::Web::FeedRenderResult.new(body: '', ttl_seconds: 600) } - let(:json_feed_result) do - Html2rss::Web::FeedRenderResult.new( - body: '{"version":"https://jsonfeed.org/version/1.1","items":[]}', - ttl_seconds: 600 + let(:feed_result) do + Html2rss::Web::Feeds::Result.new( + status: :ok, + payload: nil, + message: nil, + ttl_seconds: 600, + cache_key: 'feed_result:test' ) end @@ -63,12 +65,10 @@ allow(Html2rss::Web::AccountManager).to receive(:get_account_by_username).and_return(account) allow(Html2rss::Web::UrlValidator).to receive(:url_allowed?).and_return(true) allow(Html2rss::Web::AutoSource).to receive(:enabled?).and_return(true) - allow(Html2rss::Web::AutoSource).to receive(:generate_feed_result) - .with(anything, anything, format: Html2rss::Web::FeedResponseFormat::RSS) - .and_return(feed_result) - allow(Html2rss::Web::AutoSource).to receive(:generate_feed_result) - .with(anything, anything, format: Html2rss::Web::FeedResponseFormat::JSON_FEED) - .and_return(json_feed_result) + allow(Html2rss::Web::Feeds::Service).to receive(:call).and_return(feed_result) + allow(Html2rss::Web::Feeds::RssRenderer).to receive(:call).and_return('') + allow(Html2rss::Web::Feeds::JsonRenderer).to receive(:call) + .and_return('{"version":"https://jsonfeed.org/version/1.1","items":[]}') end describe 'GET /api/v1/feeds/:token' do # rubocop:disable RSpec/MultipleMemoizedHelpers From 044ee9666750ddf14c32072df28cfe19fab3a35c Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sat, 14 Mar 2026 15:50:31 +0100 Subject: [PATCH 04/11] refactor!: remove legacy feed-serving path --- app/app_context.rb | 6 +- app/auto_source.rb | 26 --- app/feed_generator.rb | 132 -------------- app/feed_render_result.rb | 9 - app/feed_request_handler.rb | 64 ------- app/feed_runtime.rb | 166 ------------------ .../html2rss/web/feed_request_handler_spec.rb | 51 ------ spec/html2rss/web/feed_runtime_spec.rb | 37 ---- spec/html2rss/web/feeds/cache_spec.rb | 53 ++++++ spec/html2rss/web/feeds/service_spec.rb | 92 ++++++++++ 10 files changed, 146 insertions(+), 490 deletions(-) delete mode 100644 app/feed_generator.rb delete mode 100644 app/feed_render_result.rb delete mode 100644 app/feed_request_handler.rb delete mode 100644 app/feed_runtime.rb delete mode 100644 spec/html2rss/web/feed_request_handler_spec.rb delete mode 100644 spec/html2rss/web/feed_runtime_spec.rb create mode 100644 spec/html2rss/web/feeds/cache_spec.rb create mode 100644 spec/html2rss/web/feeds/service_spec.rb diff --git a/app/app_context.rb b/app/app_context.rb index 26b4d96e..0c24cb5c 100644 --- a/app/app_context.rb +++ b/app/app_context.rb @@ -17,8 +17,6 @@ module AppContext :auth, :security_logger, :observability, - :feed_runtime, - :feed_request_handler, :routes_api_v1, :routes_static, :api_health, @@ -42,9 +40,7 @@ def core_dependencies flags: Flags, auth: Auth, security_logger: SecurityLogger, - observability: Observability, - feed_runtime: FeedRuntime, - feed_request_handler: FeedRequestHandler + observability: Observability } end diff --git a/app/auto_source.rb b/app/auto_source.rb index 4c2c2687..62d898eb 100644 --- a/app/auto_source.rb +++ b/app/auto_source.rb @@ -4,8 +4,6 @@ require_relative 'account_manager' require_relative 'auth' require_relative 'boundary_models' -require_relative 'feed_response_format' -require_relative 'feed_generator' require_relative 'url_validator' module Html2rss @@ -63,30 +61,6 @@ def generate_feed_from_stable_id(feed_id, token_data) } end - # @param url [String] - # @param strategy [String] - # @param format [Symbol] - # @return [Html2rss::Web::FeedRenderResult] - def generate_feed_result(url, strategy = 'ssrf_filter', format: FeedResponseFormat::RSS) - FeedGenerator.generate_feed_result(url, strategy, format:) - end - - # @param url [String] - # @param strategy [String] - # @param format [Symbol] - # @return [Object] raw feed object from selected strategy. - def generate_feed_object(url, strategy = 'ssrf_filter', format: FeedResponseFormat::RSS) - FeedGenerator.call_strategy(url, strategy, format:) - end - - # @param url [String] - # @param strategy [String] - # @param format [Symbol] - # @return [String] rendered RSS/XML content. - def generate_feed_content(url, strategy = 'ssrf_filter', format: FeedResponseFormat::RSS) - generate_feed_result(url, strategy, format:).body - end - private # @param token_data [Hash{Symbol=>Object}] diff --git a/app/feed_generator.rb b/app/feed_generator.rb deleted file mode 100644 index 423f9d55..00000000 --- a/app/feed_generator.rb +++ /dev/null @@ -1,132 +0,0 @@ -# frozen_string_literal: true - -require_relative 'cache_ttl' -require_relative 'feed_render_result' -require_relative 'xml_builder' -require_relative 'feed_response_format' -require_relative 'json_feed_builder' -require_relative 'local_config' - -module Html2rss - module Web - ## - # Feed generation functionality - module FeedGenerator - class << self - # @param url [String] - # @param strategy [String, Symbol] - # @param format [Symbol] - # @return [Html2rss::Web::FeedRenderResult] - def generate_feed_result(url, strategy = 'ssrf_filter', format: FeedResponseFormat::RSS) - config = strategy_config(url, strategy) - feed_content = generate_from_config(config, format) - body = process_feed_content(url, strategy, feed_content, format:) - - FeedRenderResult.new(body:, ttl_seconds: ttl_seconds_for(config)) - end - - # @param url [String] - # @param strategy [String, Symbol] - # @param format [Symbol] - # @return [String] - def generate_feed_content(url, strategy = 'ssrf_filter', format: FeedResponseFormat::RSS) - generate_feed_result(url, strategy, format:).body - end - - # @param url [String] - # @param strategy [String, Symbol] - # @param format [Symbol] - # @return [String] - def call_strategy(url, strategy, format: FeedResponseFormat::RSS) - return nil if url.nil? || url.empty? - - generate_from_config(strategy_config(url, strategy), format) - end - - # @param url [String] - # @param strategy [String, Symbol] - # @param feed_content [RSS::Rss, Hash, nil] - # @param format [Symbol] - # @return [String] - def process_feed_content(url, strategy, feed_content, format: FeedResponseFormat::RSS) - return error_feed('URL parameter required', format:) if feed_content.nil? - return rendered_feed(feed_content, format) if feed_has_items?(feed_content) - - create_empty_feed_warning(url: url, strategy: strategy, format: format) - end - - private - - # @param feed [RSS::Rss, Hash] - # @return [Boolean] - def feed_has_items?(feed) - return !Array(feed[:items] || feed['items']).empty? if feed.is_a?(Hash) - - feed.respond_to?(:items) && !feed.items.empty? - end - - # @param feed [RSS::Rss, Hash] - # @param format [Symbol] - # @return [String] - def rendered_feed(feed, format) - return JSON.generate(feed) if format == FeedResponseFormat::JSON_FEED - - feed.to_s - end - - # @param url [String] - # @param strategy [String, Symbol] - # @param format [Symbol] - # @return [String] - def create_empty_feed_warning(url:, strategy:, format:) - builder_for(format).build_empty_feed_warning( - url: url, - strategy: strategy, - site_title: Html2rss::Url.for_channel(url).channel_titleized - ) - end - - # @param message [String] - # @param format [Symbol] - # @return [String] - def error_feed(message, format:) - builder_for(format).build_error_feed(message: message) - end - - # @param format [Symbol] - # @return [Module] - def builder_for(format) - format == FeedResponseFormat::JSON_FEED ? JsonFeedBuilder : XmlBuilder - end - - # @param config [Hash{Symbol=>Object}] - # @param format [Symbol] - # @return [RSS::Rss, Hash] - def generate_from_config(config, format) - return Html2rss.json_feed(config) if format == FeedResponseFormat::JSON_FEED - - Html2rss.feed(config) - end - - # @param url [String] - # @param strategy [String, Symbol] - # @return [Hash{Symbol=>Object}] - def strategy_config(url, strategy) - LocalConfig.global - .slice(:stylesheets, :headers) - .merge( - strategy: strategy.to_sym, - channel: { url: url }, - auto_source: {} - ) - end - - # @param config [Hash{Symbol=>Object}] - # @return [Integer] - def ttl_seconds_for(config) - CacheTtl.seconds_from_minutes(config.dig(:channel, :ttl)) - end - end - end - end -end diff --git a/app/feed_render_result.rb b/app/feed_render_result.rb deleted file mode 100644 index 7965eb8a..00000000 --- a/app/feed_render_result.rb +++ /dev/null @@ -1,9 +0,0 @@ -# frozen_string_literal: true - -module Html2rss - module Web - ## - # Immutable feed response payload plus cache metadata. - FeedRenderResult = Data.define(:body, :ttl_seconds) - end -end diff --git a/app/feed_request_handler.rb b/app/feed_request_handler.rb deleted file mode 100644 index 504ca665..00000000 --- a/app/feed_request_handler.rb +++ /dev/null @@ -1,64 +0,0 @@ -# frozen_string_literal: true - -require 'digest' - -require_relative 'cache_ttl' -require_relative 'feed_response_format' -require_relative 'feed_runtime' -require_relative 'feeds' -require_relative 'local_config' - -module Html2rss - module Web - ## - # Resolves feed payload and TTL for route handlers. - module FeedRequestHandler - class << self - # @param feed_name [String] - # @param params [Hash] - # @param format [Symbol] - # @param async_refresh [Boolean] - # @return [Array<(String, Integer)>] feed xml and cache ttl. - def call(feed_name:, params:, format:, async_refresh: false) - ttl_seconds = feed_ttl_seconds(feed_name) - content = feed_content(feed_name, params, format, ttl_seconds, async_refresh) - [content, ttl_seconds] - end - - private - - # @param feed_name [String] - # @return [Integer] - def feed_ttl_seconds(feed_name) - ttl_value = LocalConfig.find(feed_name)&.dig(:channel, :ttl) - CacheTtl.seconds_from_minutes(ttl_value) - end - - # @param feed_name [String] - # @param params [Hash] - # @param format [Symbol] - # @param ttl_seconds [Integer] - # @param async_refresh [Boolean] - # @return [String] - def feed_content(feed_name, params, format, ttl_seconds, async_refresh) - FeedRuntime.read( - key: feed_cache_key(feed_name, params), - ttl_seconds: ttl_seconds, - async_refresh: async_refresh - ) do - Feeds.generate_feed(feed_name, params, format:) - end - end - - # @param feed_name [String] - # @param params [Hash] - # @return [String] - def feed_cache_key(feed_name, params) - normalized_params = params.to_h.sort_by { |key, _| key.to_s } - digest = Digest::SHA256.hexdigest(Marshal.dump(normalized_params)) - "local_feed:#{feed_name}:#{digest}" - end - end - end - end -end diff --git a/app/feed_runtime.rb b/app/feed_runtime.rb deleted file mode 100644 index ddd12770..00000000 --- a/app/feed_runtime.rb +++ /dev/null @@ -1,166 +0,0 @@ -# frozen_string_literal: true - -require 'digest' -require 'time' - -require_relative 'security_logger' -require_relative 'flags' - -module Html2rss - module Web - ## - # Cache-first feed runtime with optional async stale refresh. - module FeedRuntime - ## - # Cached feed entry model. - Entry = Data.define(:content, :cached_at, :expires_at) - - class << self - # @param key [String] - # @param ttl_seconds [Integer] - # @param async_refresh [Boolean] - # @param generator [Proc] - # @yieldreturn [String] - # @return [String] - def read(key:, ttl_seconds:, async_refresh: false, &generator) - return yield.to_s unless async_refresh - - entry = fetch_entry(key) - return generate_and_store(key, ttl_seconds, &generator) unless entry - - return entry.content if fresh?(entry) - - enqueue_refresh(key, ttl_seconds, &generator) if within_stale_window?(entry, ttl_seconds) - entry.content - end - - # @param reason [String] - # @return [nil] - def clear!(reason: 'manual') - mutex.synchronize do - @cache = {} - @pending_keys = Set.new - end - SecurityLogger.log_cache_lifecycle('feed_runtime', 'clear', reason: reason) - nil - end - - private - - # @param key [String] - # @return [Entry, nil] - def fetch_entry(key) - mutex.synchronize { cache[key] } - end - - # @param key [String] - # @param ttl_seconds [Integer] - # @yieldreturn [String] - # @return [String] - def generate_and_store(key, ttl_seconds) - content = yield.to_s - now = Time.now.utc - entry = Entry.new(content: content, cached_at: now, expires_at: now + normalize_ttl(ttl_seconds)) - - mutex.synchronize { cache[key] = entry } - SecurityLogger.log_cache_lifecycle('feed_runtime', 'sync_write', key_hash: key_hash(key)) - content - end - - # @param key [String] - # @param ttl_seconds [Integer] - # @yieldreturn [String] - # @return [void] - def enqueue_refresh(key, ttl_seconds, &generator) - mutex.synchronize do - return if pending_keys.include?(key) - - pending_keys.add(key) - queue << [key, ttl_seconds, generator] - end - start_worker! - SecurityLogger.log_cache_lifecycle('feed_runtime', 'enqueue_refresh', key_hash: key_hash(key)) - end - - # @return [void] - def start_worker! - return if @worker&.alive? # rubocop:disable ThreadSafety/ClassInstanceVariable - - # rubocop:disable ThreadSafety/NewThread - @worker = Thread.new do # rubocop:disable ThreadSafety/ClassInstanceVariable - Thread.current.abort_on_exception = false - process_queue - end - # rubocop:enable ThreadSafety/NewThread - end - - # @return [void] - def process_queue - loop do - key, ttl_seconds, generator = queue.pop - generate_and_store(key, ttl_seconds, &generator) - SecurityLogger.log_cache_lifecycle('feed_runtime', 'async_refresh', key_hash: key_hash(key)) - rescue StandardError => error - SecurityLogger.log_suspicious_activity('feed_runtime', 'refresh_failure', - key_hash: key_hash(key), error: error.message) - ensure - mutex.synchronize { pending_keys.delete(key) if key } - end - end - - # @param entry [Entry] - # @return [Boolean] - def fresh?(entry) - Time.now.utc < entry.expires_at - end - - # @param entry [Entry] - # @param ttl_seconds [Integer] - # @return [Boolean] - def within_stale_window?(entry, ttl_seconds) - stale_seconds = normalize_ttl(ttl_seconds) * stale_factor - Time.now.utc <= (entry.expires_at + stale_seconds) - end - - # @return [Queue] - def queue - @queue ||= Queue.new # rubocop:disable ThreadSafety/ClassInstanceVariable - end - - # @return [Hash{String=>Entry}] - def cache - @cache ||= {} # rubocop:disable ThreadSafety/ClassInstanceVariable - end - - # @return [Set] - def pending_keys - @pending_keys ||= Set.new # rubocop:disable ThreadSafety/ClassInstanceVariable - end - - # @return [Mutex] - def mutex - @mutex ||= Mutex.new # rubocop:disable ThreadSafety/ClassInstanceVariable - end - - # @param key [String] - # @return [String] - def key_hash(key) - Digest::SHA256.hexdigest(key)[0..11] - end - - # @param ttl_seconds [Integer] - # @return [Integer] - def normalize_ttl(ttl_seconds) - value = ttl_seconds.to_i - value.positive? ? value : 300 - end - - # @return [Integer] - def stale_factor - factor = Flags.async_feed_refresh_stale_factor.to_i - factor.positive? ? factor : 3 - end - end - end - end -end diff --git a/spec/html2rss/web/feed_request_handler_spec.rb b/spec/html2rss/web/feed_request_handler_spec.rb deleted file mode 100644 index 04b37b98..00000000 --- a/spec/html2rss/web/feed_request_handler_spec.rb +++ /dev/null @@ -1,51 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_relative '../../../app' - -RSpec.describe Html2rss::Web::FeedRequestHandler do - describe '.call' do - let(:feed_name) { 'legacy' } - let(:params) { { 'page' => '1', 'sort' => 'recent' } } - - before do - allow(Html2rss::Web::LocalConfig).to receive(:find).with(feed_name).and_return({ channel: { ttl: 15 } }) - allow(Html2rss::Web::FeedRuntime).to receive(:read).and_yield - allow(Html2rss::Web::Feeds).to receive(:generate_feed).and_return('') - end - - def capture_runtime_reads - reads = [] - - allow(Html2rss::Web::FeedRuntime).to receive(:read) do |key:, ttl_seconds:, async_refresh:, &block| - reads << { key:, ttl_seconds:, async_refresh: } - block.call - end - - reads - end - - def exercise_both_formats(feed_name:, params:) - described_class.call(feed_name:, params:, format: Html2rss::Web::FeedResponseFormat::RSS) - described_class.call(feed_name:, params:, format: Html2rss::Web::FeedResponseFormat::JSON_FEED) - end - - it 'uses the same cache key for rss and json representations', :aggregate_failures do - reads = capture_runtime_reads - - exercise_both_formats(feed_name:, params:) - - expect(reads.map { |read| read[:key] }.uniq).to contain_exactly(reads.first[:key]) - expect(reads.map { |read| read[:ttl_seconds] }).to all(eq(900)) - expect(reads.map { |read| read[:async_refresh] }).to all(be(false)) - end - - it 'keeps ttl identical across representations', :aggregate_failures do - reads = capture_runtime_reads - - exercise_both_formats(feed_name:, params:) - - expect(reads.map { |read| read[:ttl_seconds] }.uniq).to eq([900]) - end - end -end diff --git a/spec/html2rss/web/feed_runtime_spec.rb b/spec/html2rss/web/feed_runtime_spec.rb deleted file mode 100644 index dd6bcfcb..00000000 --- a/spec/html2rss/web/feed_runtime_spec.rb +++ /dev/null @@ -1,37 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -require_relative '../../../app/feed_runtime' - -RSpec.describe Html2rss::Web::FeedRuntime do - before do - described_class.clear! - end - - it 'bypasses cache when async refresh is disabled' do - expect(read_twice(key: 'k1', async_refresh: false)).to eq(%w[v1 v2]) - end - - it 'returns cached content when async refresh is enabled' do - expect(read_twice(key: 'k2', async_refresh: true)).to eq(%w[v1 v1]) - end - - private - - # @param key [String] - # @param async_refresh [Boolean] - # @return [Array] - def read_twice(key:, async_refresh:) - value = 0 - content = lambda do - value += 1 - "v#{value}" - end - - [ - described_class.read(key: key, ttl_seconds: 60, async_refresh: async_refresh, &content), - described_class.read(key: key, ttl_seconds: 60, async_refresh: async_refresh, &content) - ] - end -end diff --git a/spec/html2rss/web/feeds/cache_spec.rb b/spec/html2rss/web/feeds/cache_spec.rb new file mode 100644 index 00000000..42f425cc --- /dev/null +++ b/spec/html2rss/web/feeds/cache_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../../../../app' + +RSpec.describe Html2rss::Web::Feeds::Cache do + attr_writer :fetch_calls + + let(:result) do + Html2rss::Web::Feeds::Result.new( + status: :ok, + payload: { feed: Object.new, url: 'https://example.com', strategy: 'ssrf_filter' }, + message: nil, + ttl_seconds: 60, + cache_key: 'feed_result:test' + ) + end + + before do + described_class.clear! + end + + it 'returns the cached result on repeated reads for the same key' do + expect(read_same_key_twice).to all(eq(result)) + end + + it 'rebuilds after the cache is cleared' do + fetch_with_counter + described_class.clear!(reason: 'spec') + + expect { fetch_with_counter }.to change { fetch_calls }.from(1).to(2) + end + + private + + # @return [Array] + def read_same_key_twice + [fetch_with_counter, fetch_with_counter] + end + + # @return [Html2rss::Web::Feeds::Result] + def fetch_with_counter + described_class.fetch('feed_result:test', ttl_seconds: 60) do + self.fetch_calls += 1 + result + end + end + + # @return [Integer] + def fetch_calls + @fetch_calls ||= 0 + end +end diff --git a/spec/html2rss/web/feeds/service_spec.rb b/spec/html2rss/web/feeds/service_spec.rb new file mode 100644 index 00000000..7c96c6f0 --- /dev/null +++ b/spec/html2rss/web/feeds/service_spec.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../../../../app' + +RSpec.describe Html2rss::Web::Feeds::Service do + subject(:result) { described_class.call(resolved_source) } + + let(:resolved_source) do + Html2rss::Web::Feeds::ResolvedSource.new( + source_kind: :static, + cache_identity: 'example-feed:abc123', + generator_input: { + strategy: :ssrf_filter, + channel: { url: 'https://example.com/articles' }, + auto_source: {} + }, + ttl_seconds: 900 + ) + end + + before do + Html2rss::Web::Feeds::Cache.clear! + end + + context 'when feed generation succeeds with items' do + let(:feed) { Struct.new(:items).new([Object.new]) } + + before do + allow(Html2rss).to receive(:feed).with(resolved_source.generator_input).and_return(feed) + end + + it 'marks the result as ok' do + expect(result.status).to eq(:ok) + end + + it 'preserves the source ttl' do + expect(result.ttl_seconds).to eq(900) + end + + it 'uses the canonical source cache key' do + expect(result.cache_key).to eq('feed_result:example-feed:abc123') + end + + it 'retains the canonical payload metadata' do + expect(result.payload).to eq( + feed: feed, + url: 'https://example.com/articles', + strategy: 'ssrf_filter' + ) + end + + it 'reuses the cached result for repeated requests' do + described_class.call(resolved_source) + described_class.call(resolved_source) + + expect(Html2rss).to have_received(:feed).once + end + end + + context 'when the generated feed has no items' do + before do + allow(Html2rss).to receive(:feed).with(resolved_source.generator_input).and_return(Struct.new(:items).new([])) + end + + it 'marks the result as empty' do + expect(result.status).to eq(:empty) + end + + it 'keeps the result message empty' do + expect(result.message).to be_nil + end + end + + context 'when generation fails' do + before do + allow(Html2rss).to receive(:feed).with(resolved_source.generator_input).and_raise(StandardError, 'boom') + end + + it 'marks the result as an error' do + expect(result.status).to eq(:error) + end + + it 'returns the generator error message' do + expect(result.message).to eq('boom') + end + + it 'drops the feed payload' do + expect(result.payload).to be_nil + end + end +end From 471df83a714dd652a7f186048ff8343d3ec84b7e Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sat, 14 Mar 2026 15:52:17 +0100 Subject: [PATCH 05/11] refactor!: finalize architecture cleanup --- app.rb | 6 ++- app/feed_route_handler.rb | 85 --------------------------------- app/http/feed_route_handler.rb | 87 ++++++++++++++++++++++++++++++++++ app/routes/static.rb | 2 +- docs/adr/0006-app-context.md | 1 - 5 files changed, 93 insertions(+), 88 deletions(-) delete mode 100644 app/feed_route_handler.rb create mode 100644 app/http/feed_route_handler.rb diff --git a/app.rb b/app.rb index de1ded84..651b7bea 100644 --- a/app.rb +++ b/app.rb @@ -105,7 +105,11 @@ def development? = self.class.development? context.routes_api_v1.call(r, context: context) || context.routes_static.call(r, feed_handler: lambda { |router_ctx, feed_name| - FeedRouteHandler.call(context: context, router: router_ctx, feed_name: feed_name) + Http::FeedRouteHandler.call( + context: context, + router: router_ctx, + feed_name: feed_name + ) }, index_renderer: ->(router_ctx) { render_index_page(router_ctx) }) end diff --git a/app/feed_route_handler.rb b/app/feed_route_handler.rb deleted file mode 100644 index 370ea693..00000000 --- a/app/feed_route_handler.rb +++ /dev/null @@ -1,85 +0,0 @@ -# frozen_string_literal: true - -require_relative 'feed_response_format' -require_relative 'feeds/json_renderer' -require_relative 'feeds/request_parser' -require_relative 'feeds/resolver' -require_relative 'feeds/rss_renderer' -require_relative 'feeds/service' -require_relative 'http_cache' - -module Html2rss - module Web - ## - # Handles legacy/static feed route rendering concerns. - module FeedRouteHandler - class << self - # @param context [Html2rss::Web::AppContext::Context] - # @param router [Roda::RodaRequest] - # @param feed_name [String] - # @return [String] - def call(context:, router:, feed_name:) - feed_request = Feeds::RequestParser.call(request: router, target_kind: :static, identifier: feed_name) - resolved_source = Feeds::Resolver.call(feed_request) - result = Feeds::Service.call(resolved_source) - - raise InternalServerError, result.message if result.status == :error - - emit_success(context, feed_name, resolved_source.generator_input[:strategy]) - configure_response(router, result.ttl_seconds, feed_request.representation) - render_result(result, feed_request.representation) - rescue StandardError => error - emit_failure(context, feed_name, error) - raise - end - - private - - # @param context [Html2rss::Web::AppContext::Context] - # @param feed_name [String] - # @param strategy [String, nil] - # @return [void] - def emit_success(context, feed_name, strategy) - context.observability.emit( - event_name: 'feed.render', - outcome: 'success', - details: { feed_name: feed_name, strategy: strategy }, - level: :info - ) - end - - # @param context [Html2rss::Web::AppContext::Context] - # @param feed_name [String] - # @param error [StandardError] - # @return [void] - def emit_failure(context, feed_name, error) - context.observability.emit( - event_name: 'feed.render', - outcome: 'failure', - details: { feed_name: feed_name, error_class: error.class.name, error_message: error.message }, - level: :warn - ) - end - - # @param result [Html2rss::Web::Feeds::Result] - # @param format [Symbol] - # @return [String] - def render_result(result, format) - return Feeds::JsonRenderer.call(result) if format == Feeds::ResponseFormat::JSON_FEED - - Feeds::RssRenderer.call(result) - end - - # @param router [Roda::RodaRequest] - # @param ttl_seconds [Integer] - # @param format [Symbol] - # @return [void] - def configure_response(router, ttl_seconds, format) - router.response['Content-Type'] = FeedResponseFormat.content_type(format) - HttpCache.expires(router.response, ttl_seconds, cache_control: 'public') - HttpCache.vary(router.response, 'Accept') - end - end - end - end -end diff --git a/app/http/feed_route_handler.rb b/app/http/feed_route_handler.rb new file mode 100644 index 00000000..87638f88 --- /dev/null +++ b/app/http/feed_route_handler.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +require_relative '../feed_response_format' +require_relative '../feeds/json_renderer' +require_relative '../feeds/request_parser' +require_relative '../feeds/resolver' +require_relative '../feeds/rss_renderer' +require_relative '../feeds/service' +require_relative '../http_cache' + +module Html2rss + module Web + module Http + ## + # Renders static feed requests through the shared feed pipeline. + module FeedRouteHandler + class << self + # @param context [Html2rss::Web::AppContext::Context] + # @param router [Roda::RodaRequest] + # @param feed_name [String] + # @return [String] + def call(context:, router:, feed_name:) + feed_request = Feeds::RequestParser.call(request: router, target_kind: :static, identifier: feed_name) + resolved_source = Feeds::Resolver.call(feed_request) + result = Feeds::Service.call(resolved_source) + + raise InternalServerError, result.message if result.status == :error + + emit_success(context, feed_name, resolved_source.generator_input[:strategy]) + configure_response(router, result.ttl_seconds, feed_request.representation) + render_result(result, feed_request.representation) + rescue StandardError => error + emit_failure(context, feed_name, error) + raise + end + + private + + # @param context [Html2rss::Web::AppContext::Context] + # @param feed_name [String] + # @param strategy [String, nil] + # @return [void] + def emit_success(context, feed_name, strategy) + context.observability.emit( + event_name: 'feed.render', + outcome: 'success', + details: { feed_name: feed_name, strategy: strategy }, + level: :info + ) + end + + # @param context [Html2rss::Web::AppContext::Context] + # @param feed_name [String] + # @param error [StandardError] + # @return [void] + def emit_failure(context, feed_name, error) + context.observability.emit( + event_name: 'feed.render', + outcome: 'failure', + details: { feed_name: feed_name, error_class: error.class.name, error_message: error.message }, + level: :warn + ) + end + + # @param result [Html2rss::Web::Feeds::Result] + # @param format [Symbol] + # @return [String] + def render_result(result, format) + return Feeds::JsonRenderer.call(result) if format == Feeds::ResponseFormat::JSON_FEED + + Feeds::RssRenderer.call(result) + end + + # @param router [Roda::RodaRequest] + # @param ttl_seconds [Integer] + # @param format [Symbol] + # @return [void] + def configure_response(router, ttl_seconds, format) + router.response['Content-Type'] = FeedResponseFormat.content_type(format) + HttpCache.expires(router.response, ttl_seconds, cache_control: 'public') + HttpCache.vary(router.response, 'Accept') + end + end + end + end + end +end diff --git a/app/routes/static.rb b/app/routes/static.rb index 7deafcb0..6657f53a 100644 --- a/app/routes/static.rb +++ b/app/routes/static.rb @@ -6,7 +6,7 @@ module Html2rss module Web module Routes ## - # Mounts non-API routes (root page + legacy feed paths). + # Mounts non-API routes (root page + static feed paths). # # This remains minimal by receiving handlers from the caller, keeping # routing concerns separate from rendering/business logic. diff --git a/docs/adr/0006-app-context.md b/docs/adr/0006-app-context.md index 7192300b..5bdab59f 100644 --- a/docs/adr/0006-app-context.md +++ b/docs/adr/0006-app-context.md @@ -14,7 +14,6 @@ Introduce `Html2rss::Web::AppContext` as the single dependency wiring root used - auth (`Auth`) - flags (`Flags`) - logging/observability (`SecurityLogger`, `Observability`) -- runtime (`FeedRuntime`, `FeedRequestHandler`) - API handlers (`Api::V1::*`) - route assemblers (`Routes::*`) From 0b8c6510d7496334b56305e4aeababcd8d03ddeb Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sat, 14 Mar 2026 15:56:03 +0100 Subject: [PATCH 06/11] refactor!: remove dead feed helper --- app/feeds.rb | 34 ------------------------- spec/html2rss/web/app_spec.rb | 47 ++++++++++++++++++++++++----------- 2 files changed, 33 insertions(+), 48 deletions(-) delete mode 100644 app/feeds.rb diff --git a/app/feeds.rb b/app/feeds.rb deleted file mode 100644 index 162ca158..00000000 --- a/app/feeds.rb +++ /dev/null @@ -1,34 +0,0 @@ -# frozen_string_literal: true - -require 'html2rss' -require 'json' - -require_relative 'auth' -require_relative 'feed_response_format' -require_relative 'local_config' - -module Html2rss - module Web - ## - # Feeds functionality for generating RSS feeds - module Feeds - class << self - # Builds and renders a configured RSS feed by feed name. - # - # @param feed_name [String] - # @param params [Hash{Symbol=>Object}] - # @param format [Symbol] - # @return [String] rendered feed output. - def generate_feed(feed_name, params = {}, format: FeedResponseFormat::RSS) - config = LocalConfig.find(feed_name) - config[:params] = (config[:params] || {}).merge(params) if params.any? - config[:strategy] ||= Html2rss::RequestService.default_strategy_name - - return JSON.generate(Html2rss.json_feed(config)) if format == FeedResponseFormat::JSON_FEED - - Html2rss.feed(config).to_s - end - end - end - end -end diff --git a/spec/html2rss/web/app_spec.rb b/spec/html2rss/web/app_spec.rb index 95d971dc..6ab9521d 100644 --- a/spec/html2rss/web/app_spec.rb +++ b/spec/html2rss/web/app_spec.rb @@ -18,9 +18,30 @@ def json_feed_error_tuple ] end - def stub_legacy_feed(body) - allow(Html2rss::Web::Feeds).to receive(:generate_feed).and_return(body) - allow(Html2rss::Web::LocalConfig).to receive(:find).and_return({ channel: { ttl: 180 } }) + def static_feed_json + '{"version":"https://jsonfeed.org/version/1.1"}' + end + + def stub_static_feed(rss_body: '', json_body: static_feed_json, ttl: 180) + allow(Html2rss::Web::LocalConfig).to receive(:find).and_return({ channel: { ttl: ttl } }) + + stub_static_renderers(static_feed_result(ttl:), rss_body:, json_body:) + end + + def static_feed_result(ttl:) + Html2rss::Web::Feeds::Result.new( + status: :ok, + payload: nil, + message: nil, + ttl_seconds: Html2rss::Web::CacheTtl.seconds_from_minutes(ttl), + cache_key: 'feed_result:spec' + ) + end + + def stub_static_renderers(result, rss_body:, json_body:) + allow(Html2rss::Web::Feeds::Service).to receive(:call).and_return(result) + allow(Html2rss::Web::Feeds::RssRenderer).to receive(:call).with(result).and_return(rss_body) + allow(Html2rss::Web::Feeds::JsonRenderer).to receive(:call).with(result).and_return(json_body) end it { expect(described_class).to be < Roda } @@ -38,9 +59,8 @@ def app = described_class expect(last_response.headers['Strict-Transport-Security']).to include('max-age=31536000') end - it 'serves legacy feed routes with caching headers', :aggregate_failures do # rubocop:disable RSpec/ExampleLength - allow(Html2rss::Web::Feeds).to receive(:generate_feed).and_return('') - allow(Html2rss::Web::LocalConfig).to receive(:find).and_return({ channel: { ttl: 180 } }) + it 'serves static feed routes with caching headers', :aggregate_failures do # rubocop:disable RSpec/ExampleLength + stub_static_feed get '/legacy' @@ -52,8 +72,8 @@ def app = described_class expect(last_response.body).to eq('') end - it 'serves legacy json feed routes when json is requested by extension', :aggregate_failures do - stub_legacy_feed('{"version":"https://jsonfeed.org/version/1.1"}') + it 'serves static json feed routes when json is requested by extension', :aggregate_failures do + stub_static_feed get '/legacy.json' expect(json_feed_response_tuple).to eq( @@ -61,8 +81,8 @@ def app = described_class ) end - it 'serves HEAD requests for legacy feed routes with negotiated headers only', :aggregate_failures do # rubocop:disable RSpec/ExampleLength - stub_legacy_feed('') + it 'serves HEAD requests for static feed routes with negotiated headers only', :aggregate_failures do # rubocop:disable RSpec/ExampleLength + stub_static_feed head '/legacy' expect(last_response.status).to eq(200) @@ -72,8 +92,7 @@ def app = described_class end it 'coerces string ttl values before cache expiry math', :aggregate_failures do - allow(Html2rss::Web::Feeds).to receive(:generate_feed).and_return('') - allow(Html2rss::Web::LocalConfig).to receive(:find).and_return({ channel: { ttl: '180' } }) + stub_static_feed(ttl: '180') get '/legacy' @@ -81,7 +100,7 @@ def app = described_class expect(last_response.headers['Cache-Control']).to include('max-age=10800') end - it 'renders XML error when legacy feed generation fails', :aggregate_failures do + it 'renders XML error when static feed generation fails', :aggregate_failures do allow(Html2rss::Web::XmlBuilder).to receive(:build_error_feed).and_return('') get '/missing-feed' @@ -91,7 +110,7 @@ def app = described_class expect(last_response.body).to eq('') end - it 'renders JSON Feed-shaped errors when legacy json feed generation fails', :aggregate_failures do + it 'renders JSON Feed-shaped errors when static json feed generation fails', :aggregate_failures do get '/missing-feed.json' expect(json_feed_error_tuple).to eq( From 698b7d05299aeca4398f9c09338993bdeb840fc0 Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sat, 14 Mar 2026 16:19:01 +0100 Subject: [PATCH 07/11] refactor!: normalize feed responses and stabilize openapi verification --- Makefile | 3 +- app/api/v1/feeds/show_feed.rb | 38 +- app/feeds/cache.rb | 14 +- app/feeds/json_renderer.rb | 8 +- app/feeds/payload.rb | 11 + app/feeds/result.rb | 2 +- app/feeds/rss_renderer.rb | 8 +- app/feeds/service.rb | 43 ++- app/http/feed_response.rb | 57 +++ app/http/feed_route_handler.rb | 35 +- docs/api/v1/openapi.yaml | 347 ++++++++---------- frontend/src/api/generated/types.gen.ts | 78 ++-- spec/html2rss/web/api/v1_spec.rb | 116 ++++-- spec/html2rss/web/app_integration_spec.rb | 13 +- spec/html2rss/web/app_spec.rb | 47 ++- spec/html2rss/web/feeds/cache_spec.rb | 10 +- spec/html2rss/web/feeds/json_renderer_spec.rb | 44 +++ spec/html2rss/web/feeds/rss_renderer_spec.rb | 44 +++ spec/html2rss/web/feeds/service_spec.rb | 43 ++- spec/html2rss/web/http/feed_response_spec.rb | 86 +++++ spec/support/openapi.rb | 19 +- 21 files changed, 716 insertions(+), 350 deletions(-) create mode 100644 app/feeds/payload.rb create mode 100644 app/http/feed_response.rb create mode 100644 spec/html2rss/web/feeds/json_renderer_spec.rb create mode 100644 spec/html2rss/web/feeds/rss_renderer_spec.rb create mode 100644 spec/html2rss/web/http/feed_response_spec.rb diff --git a/Makefile b/Makefile index ffda79f4..88b20883 100644 --- a/Makefile +++ b/Makefile @@ -89,8 +89,9 @@ yard-verify-public-docs: ## Verify essential YARD docs for all public methods in openapi: ## Regenerate docs/api/v1/openapi.yaml from request specs bundle exec rake openapi:generate -openapi-verify: openapi-client-verify ## Regenerate OpenAPI and fail if docs/api/v1/openapi.yaml or frontend client is stale +openapi-verify: ## Regenerate OpenAPI and fail if docs/api/v1/openapi.yaml or frontend client is stale bundle exec rake openapi:verify + $(MAKE) openapi-client-verify openapi-client: ## Generate frontend OpenAPI client/types from docs/api/v1/openapi.yaml @cd frontend && npm run openapi:generate diff --git a/app/api/v1/feeds/show_feed.rb b/app/api/v1/feeds/show_feed.rb index 4cad4e5c..d7d3abe9 100644 --- a/app/api/v1/feeds/show_feed.rb +++ b/app/api/v1/feeds/show_feed.rb @@ -9,7 +9,7 @@ require_relative '../../../feeds/resolver' require_relative '../../../feeds/rss_renderer' require_relative '../../../feeds/service' -require_relative '../../../http_cache' +require_relative '../../../http/feed_response' require_relative '../../../observability' module Html2rss @@ -31,9 +31,12 @@ class << self # @return [String] serialized feed response body. def call(request, token) feed_request, resolved_source, result = feed_pipeline(request, token) - configure_response(request, feed_request.representation, result.ttl_seconds) - emit_success_from(resolved_source) - render_result(result, feed_request.representation) + emit_result(resolved_source, result) + Http::FeedResponse.call( + response: request.response, + representation: feed_request.representation, + result: result + ) rescue StandardError => error emit_render_failure(error) raise @@ -52,19 +55,19 @@ def feed_pipeline(request, token) ) resolved_source = ::Html2rss::Web::Feeds::Resolver.call(feed_request) result = ::Html2rss::Web::Feeds::Service.call(resolved_source) - raise InternalServerError, result.message if result.status == :error [feed_request, resolved_source, result] end - # @param request [Rack::Request] - # @param format [Symbol] - # @param ttl_seconds [Integer] + # @param resolved_source [Html2rss::Web::Feeds::ResolvedSource] + # @param result [Html2rss::Web::Feeds::Result] # @return [void] - def configure_response(request, format, ttl_seconds) - request.response['Content-Type'] = FeedResponseFormat.content_type(format) - HttpCache.expires(request.response, ttl_seconds, cache_control: 'public') - HttpCache.vary(request.response, 'Accept') + def emit_result(resolved_source, result) + return emit_success_from(resolved_source) unless result.status == :error + + emit_render_failure( + InternalServerError.new(result.error_message || result.message || HttpError::DEFAULT_MESSAGE) + ) end # @param resolved_source [Html2rss::Web::Feeds::ResolvedSource] @@ -76,17 +79,6 @@ def emit_success_from(resolved_source) ) end - # @param result [Html2rss::Web::Feeds::Result] - # @param format [Symbol] - # @return [String] - def render_result(result, format) - if format == ::Html2rss::Web::Feeds::ResponseFormat::JSON_FEED - return ::Html2rss::Web::Feeds::JsonRenderer.call(result) - end - - ::Html2rss::Web::Feeds::RssRenderer.call(result) - end - # @param strategy [String] # @param url [String] # @return [void] diff --git a/app/feeds/cache.rb b/app/feeds/cache.rb index 0119b875..310200c3 100644 --- a/app/feeds/cache.rb +++ b/app/feeds/cache.rb @@ -17,13 +17,16 @@ module Cache class << self # @param key [String] # @param ttl_seconds [Integer] + # @param cacheable [Boolean, Proc] # @yieldreturn [Html2rss::Web::Feeds::Result] # @return [Html2rss::Web::Feeds::Result] - def fetch(key, ttl_seconds:) + def fetch(key, ttl_seconds:, cacheable: true) entry = read_entry(key) return entry.result if fresh?(entry) result = yield + return result unless cacheable_result?(cacheable, result) + write_entry(key, ttl_seconds, result) result end @@ -59,6 +62,15 @@ def write_entry(key, ttl_seconds, result) SecurityLogger.log_cache_lifecycle('feeds_cache', 'write', key_hash: key_hash(key)) end + # @param cacheable [Boolean, Proc] + # @param result [Html2rss::Web::Feeds::Result] + # @return [Boolean] + def cacheable_result?(cacheable, result) + return cacheable.call(result) if cacheable.respond_to?(:call) + + cacheable + end + # @return [Hash{String=>Entry}] def entries @entries ||= {} # rubocop:disable ThreadSafety/ClassInstanceVariable diff --git a/app/feeds/json_renderer.rb b/app/feeds/json_renderer.rb index e8465bf7..7b91e3b8 100644 --- a/app/feeds/json_renderer.rb +++ b/app/feeds/json_renderer.rb @@ -20,7 +20,7 @@ class << self def call(result) case result.status when :ok - JSON.generate(payload_for(result.payload.fetch(:feed))) + JSON.generate(payload_for(result.payload.feed)) when :empty empty_feed(result) else @@ -34,9 +34,9 @@ def call(result) # @return [String] def empty_feed(result) JsonFeedBuilder.build_empty_feed_warning( - url: result.payload.fetch(:url), - strategy: result.payload.fetch(:strategy), - site_title: result.payload.fetch(:feed).channel.title + url: result.payload.url, + strategy: result.payload.strategy, + site_title: result.payload.site_title ) end diff --git a/app/feeds/payload.rb b/app/feeds/payload.rb new file mode 100644 index 00000000..7d6b68e7 --- /dev/null +++ b/app/feeds/payload.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Html2rss + module Web + module Feeds + ## + # Normalized feed payload consumed by renderers and HTTP responders. + Payload = Data.define(:feed, :site_title, :url, :strategy) + end + end +end diff --git a/app/feeds/result.rb b/app/feeds/result.rb index d5b5d2d5..dd669085 100644 --- a/app/feeds/result.rb +++ b/app/feeds/result.rb @@ -5,7 +5,7 @@ module Web module Feeds ## # Shared feed-serving result wrapper. - Result = Data.define(:status, :payload, :message, :ttl_seconds, :cache_key) + Result = Data.define(:status, :payload, :message, :ttl_seconds, :cache_key, :error_message) end end end diff --git a/app/feeds/rss_renderer.rb b/app/feeds/rss_renderer.rb index b022b05c..f3838f3e 100644 --- a/app/feeds/rss_renderer.rb +++ b/app/feeds/rss_renderer.rb @@ -15,7 +15,7 @@ class << self def call(result) case result.status when :ok - result.payload.fetch(:feed).to_s + result.payload.feed.to_s when :empty empty_feed(result) else @@ -29,9 +29,9 @@ def call(result) # @return [String] def empty_feed(result) XmlBuilder.build_empty_feed_warning( - url: result.payload.fetch(:url), - strategy: result.payload.fetch(:strategy), - site_title: result.payload.fetch(:feed).channel.title + url: result.payload.url, + strategy: result.payload.strategy, + site_title: result.payload.site_title ) end diff --git a/app/feeds/service.rb b/app/feeds/service.rb index ba830df3..84dd920f 100644 --- a/app/feeds/service.rb +++ b/app/feeds/service.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require_relative 'cache' +require_relative 'payload' require_relative 'result' module Html2rss @@ -15,7 +16,11 @@ class << self def call(resolved_source) cache_key = "feed_result:#{resolved_source.cache_identity}" - Cache.fetch(cache_key, ttl_seconds: resolved_source.ttl_seconds) do + Cache.fetch( + cache_key, + ttl_seconds: resolved_source.ttl_seconds, + cacheable: ->(result) { result.status != :error } + ) do build_result(resolved_source, cache_key) end end @@ -27,16 +32,24 @@ def call(resolved_source) # @return [Html2rss::Web::Feeds::Result] def build_result(resolved_source, cache_key) feed = Html2rss.feed(resolved_source.generator_input) + success_result(feed, resolved_source, cache_key) + rescue StandardError => error + error_result(error, resolved_source, cache_key) + end + # @param feed [Object] + # @param resolved_source [Html2rss::Web::Feeds::ResolvedSource] + # @param cache_key [String] + # @return [Html2rss::Web::Feeds::Result] + def success_result(feed, resolved_source, cache_key) Result.new( status: result_status(feed), payload: payload_for(feed, resolved_source), message: nil, ttl_seconds: resolved_source.ttl_seconds, - cache_key: cache_key + cache_key: cache_key, + error_message: nil ) - rescue StandardError => error - error_result(error, resolved_source, cache_key) end # @param feed [Object] @@ -53,13 +66,24 @@ def result_status(feed) # @param feed [Object] # @param resolved_source [Html2rss::Web::Feeds::ResolvedSource] - # @return [Hash{Symbol=>Object}] + # @return [Html2rss::Web::Feeds::Payload] def payload_for(feed, resolved_source) - { + Payload.new( feed: feed, + site_title: site_title_for(feed, resolved_source.generator_input.dig(:channel, :url)), url: resolved_source.generator_input.dig(:channel, :url), strategy: resolved_source.generator_input[:strategy].to_s - } + ) + end + + # @param feed [Object] + # @param url [String, nil] + # @return [String] + def site_title_for(feed, url) + title = feed.respond_to?(:channel) ? feed.channel&.title.to_s.strip : '' + return title unless title.empty? + + url.to_s end # @param error [StandardError] @@ -70,9 +94,10 @@ def error_result(error, resolved_source, cache_key) Result.new( status: :error, payload: nil, - message: error.message, + message: HttpError::DEFAULT_MESSAGE, ttl_seconds: resolved_source.ttl_seconds, - cache_key: cache_key + cache_key: cache_key, + error_message: error.message ) end end diff --git a/app/http/feed_response.rb b/app/http/feed_response.rb new file mode 100644 index 00000000..49f0b81e --- /dev/null +++ b/app/http/feed_response.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require_relative '../feed_response_format' +require_relative '../feeds/json_renderer' +require_relative '../feeds/response_format' +require_relative '../feeds/rss_renderer' +require_relative '../http_cache' + +module Html2rss + module Web + module Http + ## + # Writes feed results to an HTTP response in a representation-aware way. + module FeedResponse + class << self + # @param response [Rack::Response] + # @param representation [Symbol] + # @param result [Html2rss::Web::Feeds::Result] + # @return [String] + def call(response:, representation:, result:) + response.status = response_status(result) + response['Content-Type'] = FeedResponseFormat.content_type(representation) + apply_cache_headers(response, result) + HttpCache.vary(response, 'Accept') + render_result(result, representation) + end + + private + + # @param result [Html2rss::Web::Feeds::Result] + # @return [Integer] + def response_status(result) + result.status == :error ? 500 : 200 + end + + # @param response [Rack::Response] + # @param result [Html2rss::Web::Feeds::Result] + # @return [void] + def apply_cache_headers(response, result) + return HttpCache.expires_now(response) if result.status == :error + + HttpCache.expires(response, result.ttl_seconds, cache_control: 'public') + end + + # @param result [Html2rss::Web::Feeds::Result] + # @param representation [Symbol] + # @return [String] + def render_result(result, representation) + return Feeds::JsonRenderer.call(result) if representation == Feeds::ResponseFormat::JSON_FEED + + Feeds::RssRenderer.call(result) + end + end + end + end + end +end diff --git a/app/http/feed_route_handler.rb b/app/http/feed_route_handler.rb index 87638f88..6cab02ee 100644 --- a/app/http/feed_route_handler.rb +++ b/app/http/feed_route_handler.rb @@ -6,7 +6,7 @@ require_relative '../feeds/resolver' require_relative '../feeds/rss_renderer' require_relative '../feeds/service' -require_relative '../http_cache' +require_relative 'feed_response' module Html2rss module Web @@ -24,11 +24,8 @@ def call(context:, router:, feed_name:) resolved_source = Feeds::Resolver.call(feed_request) result = Feeds::Service.call(resolved_source) - raise InternalServerError, result.message if result.status == :error - - emit_success(context, feed_name, resolved_source.generator_input[:strategy]) - configure_response(router, result.ttl_seconds, feed_request.representation) - render_result(result, feed_request.representation) + emit_result(context, feed_name, resolved_source.generator_input[:strategy], result) + FeedResponse.call(response: router.response, representation: feed_request.representation, result: result) rescue StandardError => error emit_failure(context, feed_name, error) raise @@ -62,23 +59,19 @@ def emit_failure(context, feed_name, error) ) end + # @param context [Html2rss::Web::AppContext::Context] + # @param feed_name [String] + # @param strategy [String, nil] # @param result [Html2rss::Web::Feeds::Result] - # @param format [Symbol] - # @return [String] - def render_result(result, format) - return Feeds::JsonRenderer.call(result) if format == Feeds::ResponseFormat::JSON_FEED - - Feeds::RssRenderer.call(result) - end - - # @param router [Roda::RodaRequest] - # @param ttl_seconds [Integer] - # @param format [Symbol] # @return [void] - def configure_response(router, ttl_seconds, format) - router.response['Content-Type'] = FeedResponseFormat.content_type(format) - HttpCache.expires(router.response, ttl_seconds, cache_control: 'public') - HttpCache.vary(router.response, 'Accept') + def emit_result(context, feed_name, strategy, result) + return emit_success(context, feed_name, strategy) unless result.status == :error + + emit_failure( + context, + feed_name, + InternalServerError.new(result.error_message || result.message || HttpError::DEFAULT_MESSAGE) + ) end end end diff --git a/docs/api/v1/openapi.yaml b/docs/api/v1/openapi.yaml index 925bc47c..094469ee 100644 --- a/docs/api/v1/openapi.yaml +++ b/docs/api/v1/openapi.yaml @@ -18,54 +18,50 @@ servers: paths: "/": get: - summary: API metadata - tags: - - Root + description: API metadata operationId: getApiMetadata - security: [] responses: '200': - description: returns OpenAPI document URL in metadata content: application/json: schema: - type: object properties: - success: - type: boolean data: - type: object properties: api: - type: object properties: - name: - type: string description: type: string + name: + type: string openapi_url: type: string required: - name - description - openapi_url + type: object required: - api + type: object + success: + type: boolean required: - success - data - description: API metadata + type: object + description: returns OpenAPI document URL in metadata + security: [] + summary: API metadata + tags: + - Root "/feeds": post: - summary: Create a feed - tags: - - Feeds + description: Create a feed operationId: createFeed - security: - - BearerAuth: [] parameters: - - name: Authorization - in: header + - in: header + name: Authorization required: true schema: type: string @@ -73,45 +69,39 @@ paths: content: application/json: schema: - type: object properties: - url: - type: string strategy: type: string + url: + type: string required: - url - strategy + type: object responses: '201': - description: creates a feed when request is valid content: application/json: schema: - type: object properties: - success: - type: boolean data: - type: object properties: feed: - type: object properties: + created_at: + type: string id: type: string name: type: string - url: - type: string - strategy: - type: string public_url: type: string - created_at: + strategy: type: string updated_at: type: string + url: + type: string required: - id - name @@ -120,250 +110,226 @@ paths: - public_url - created_at - updated_at + type: object required: - feed - meta: type: object + meta: properties: created: type: boolean required: - created + type: object + success: + type: boolean required: - success - data - meta + type: object + description: creates a feed when request is valid '401': - description: returns 401 with UNAUTHORIZED error payload content: application/json: schema: - type: object properties: - success: - type: boolean error: - type: object properties: - message: - type: string code: type: string + message: + type: string required: - message - code + type: object + success: + type: boolean required: - success - error + type: object + description: returns 401 with UNAUTHORIZED error payload '403': - description: returns forbidden for authenticated requests when auto source - is disabled content: application/json: schema: - type: object properties: - success: - type: boolean error: - type: object properties: - message: - type: string code: type: string + message: + type: string required: - message - code + type: object + success: + type: boolean required: - success - error - description: Create a feed - "/feeds/{token}": - get: - summary: Render feed by token + type: object + description: returns forbidden for authenticated requests when auto source + is disabled + security: + - BearerAuth: [] + summary: Create a feed tags: - Feeds + "/feeds/{token}": + get: + description: Render feed by token operationId: renderFeedByToken - security: [] + parameters: + - in: path + name: token + required: true + schema: + type: string responses: - '403': - description: returns forbidden when auto source is disabled + '200': content: - application/json: + application/xml: schema: - type: object - properties: - success: - type: boolean - error: - type: object - properties: - message: - type: string - code: - type: string - required: - - message - - code - required: - - success - - error - '200': + type: string description: renders feed for a valid token + '401': + content: + application/feed+json: + schema: + type: string + description: returns JSON Feed-shaped errors when requested by json extension + '403': content: application/xml: schema: type: string - '401': - description: returns unauthorized for invalid tokens + description: returns forbidden when auto source is disabled + '500': content: - application/json: + application/feed+json: schema: - type: object - properties: - success: - type: boolean - error: - type: object - properties: - message: - type: string - code: - type: string - required: - - message - - code - required: - - success - - error - description: Render feed by token - parameters: - - name: token - in: path - required: true - schema: - type: string + type: string + description: returns non-cacheable json feed errors when service generation + fails + security: [] + summary: Render feed by token + tags: + - Feeds "/health": get: - summary: Authenticated health check - tags: - - Health + description: Authenticated health check operationId: getHealthStatus - security: - - BearerAuth: [] parameters: - - name: Authorization - in: header + - in: header + name: Authorization required: true schema: type: string responses: '200': - description: returns health status when token is valid content: application/json: schema: - type: object properties: - success: - type: boolean data: - type: object properties: health: - type: object properties: + checks: + properties: {} + type: object + environment: + type: string status: type: string timestamp: type: string - environment: - type: string uptime: - type: number format: float - checks: - type: object - properties: {} + type: number required: - status - timestamp - environment - uptime - checks + type: object required: - health + type: object + success: + type: boolean required: - success - data + type: object + description: returns health status when token is valid '401': - description: returns 401 with UNAUTHORIZED error payload content: application/json: schema: - type: object properties: - success: - type: boolean error: - type: object properties: - message: - type: string code: type: string + message: + type: string required: - message - code + type: object + success: + type: boolean required: - success - error + type: object + description: returns 401 with UNAUTHORIZED error payload '500': - description: returns error when configuration fails content: application/json: schema: - type: object properties: - success: - type: boolean error: - type: object properties: - message: - type: string code: type: string + message: + type: string required: - message - code + type: object + success: + type: boolean required: - success - error - description: Authenticated health check - "/health/live": - get: - summary: Liveness probe + type: object + description: returns error when configuration fails + security: + - BearerAuth: [] + summary: Authenticated health check tags: - Health + "/health/live": + get: + description: Liveness probe operationId: getLivenessProbe - security: [] responses: '200': - description: returns liveness status without authentication content: application/json: schema: - type: object properties: - success: - type: boolean data: - type: object properties: health: - type: object properties: status: type: string @@ -372,123 +338,132 @@ paths: required: - status - timestamp + type: object required: - health + type: object + success: + type: boolean required: - success - data - description: Liveness probe - "/health/ready": - get: - summary: Readiness probe + type: object + description: returns liveness status without authentication + security: [] + summary: Liveness probe tags: - Health + "/health/ready": + get: + description: Readiness probe operationId: getReadinessProbe - security: [] responses: '200': - description: returns readiness status without authentication content: application/json: schema: - type: object properties: - success: - type: boolean data: - type: object properties: health: - type: object properties: + checks: + properties: {} + type: object + environment: + type: string status: type: string timestamp: type: string - environment: - type: string uptime: - type: number format: float - checks: - type: object - properties: {} + type: number required: - status - timestamp - environment - uptime - checks + type: object required: - health + type: object + success: + type: boolean required: - success - data - description: Readiness probe + type: object + description: returns readiness status without authentication + security: [] + summary: Readiness probe + tags: + - Health "/openapi.yaml": get: - summary: OpenAPI specification - tags: - - Root + description: OpenAPI specification operationId: getOpenApiSpec - security: [] responses: '200': - description: serves the OpenAPI document as YAML content: application/yaml: schema: type: string - description: OpenAPI specification + description: serves the OpenAPI document as YAML + security: [] + summary: OpenAPI specification + tags: + - Root "/strategies": get: - summary: List extraction strategies - tags: - - Strategies + description: List extraction strategies operationId: listStrategies - security: [] responses: '200': - description: returns available strategies content: application/json: schema: - type: object properties: - success: - type: boolean data: - type: object properties: strategies: - type: array items: - type: object properties: + display_name: + type: string id: type: string name: type: string - display_name: - type: string required: - id - name - display_name + type: object + type: array required: - strategies - meta: type: object + meta: properties: total: type: integer required: - total + type: object + success: + type: boolean required: - success - data - meta - description: List extraction strategies + type: object + description: returns available strategies + security: [] + summary: List extraction strategies + tags: + - Strategies components: securitySchemes: BearerAuth: diff --git a/frontend/src/api/generated/types.gen.ts b/frontend/src/api/generated/types.gen.ts index 06e3329e..8f3919c4 100644 --- a/frontend/src/api/generated/types.gen.ts +++ b/frontend/src/api/generated/types.gen.ts @@ -16,14 +16,14 @@ export type GetApiMetadataResponses = { * returns OpenAPI document URL in metadata */ 200: { - success: boolean; data: { api: { - name: string; description: string; + name: string; openapi_url: string; }; }; + success: boolean; }; }; @@ -31,8 +31,8 @@ export type GetApiMetadataResponse = GetApiMetadataResponses[keyof GetApiMetadat export type CreateFeedData = { body?: { - url: string; strategy: string; + url: string; }; headers: { Authorization: string; @@ -47,21 +47,21 @@ export type CreateFeedErrors = { * returns 401 with UNAUTHORIZED error payload */ 401: { - success: boolean; error: { - message: string; code: string; + message: string; }; + success: boolean; }; /** * returns forbidden for authenticated requests when auto source is disabled */ 403: { - success: boolean; error: { - message: string; code: string; + message: string; }; + success: boolean; }; }; @@ -72,21 +72,21 @@ export type CreateFeedResponses = { * creates a feed when request is valid */ 201: { - success: boolean; data: { feed: { + created_at: string; id: string; name: string; - url: string; - strategy: string; public_url: string; - created_at: string; + strategy: string; updated_at: string; + url: string; }; }; meta: { created: boolean; }; + success: boolean; }; }; @@ -103,25 +103,17 @@ export type RenderFeedByTokenData = { export type RenderFeedByTokenErrors = { /** - * returns unauthorized for invalid tokens + * returns JSON Feed-shaped errors when requested by json extension */ - 401: { - success: boolean; - error: { - message: string; - code: string; - }; - }; + 401: string; /** * returns forbidden when auto source is disabled */ - 403: { - success: boolean; - error: { - message: string; - code: string; - }; - }; + 403: Blob | File; + /** + * returns non-cacheable json feed errors when service generation fails + */ + 500: string; }; export type RenderFeedByTokenError = RenderFeedByTokenErrors[keyof RenderFeedByTokenErrors]; @@ -150,21 +142,21 @@ export type GetHealthStatusErrors = { * returns 401 with UNAUTHORIZED error payload */ 401: { - success: boolean; error: { - message: string; code: string; + message: string; }; + success: boolean; }; /** * returns error when configuration fails */ 500: { - success: boolean; error: { - message: string; code: string; + message: string; }; + success: boolean; }; }; @@ -175,18 +167,18 @@ export type GetHealthStatusResponses = { * returns health status when token is valid */ 200: { - success: boolean; data: { health: { - status: string; - timestamp: string; - environment: string; - uptime: number; checks: { [key: string]: unknown; }; + environment: string; + status: string; + timestamp: string; + uptime: number; }; }; + success: boolean; }; }; @@ -204,13 +196,13 @@ export type GetLivenessProbeResponses = { * returns liveness status without authentication */ 200: { - success: boolean; data: { health: { status: string; timestamp: string; }; }; + success: boolean; }; }; @@ -228,18 +220,18 @@ export type GetReadinessProbeResponses = { * returns readiness status without authentication */ 200: { - success: boolean; data: { health: { - status: string; - timestamp: string; - environment: string; - uptime: number; checks: { [key: string]: unknown; }; + environment: string; + status: string; + timestamp: string; + uptime: number; }; }; + success: boolean; }; }; @@ -273,17 +265,17 @@ export type ListStrategiesResponses = { * returns available strategies */ 200: { - success: boolean; data: { strategies: Array<{ + display_name: string; id: string; name: string; - display_name: string; }>; }; meta: { total: number; }; + success: boolean; }; }; diff --git a/spec/html2rss/web/api/v1_spec.rb b/spec/html2rss/web/api/v1_spec.rb index f3df6a0d..c411b6a4 100644 --- a/spec/html2rss/web/api/v1_spec.rb +++ b/spec/html2rss/web/api/v1_spec.rb @@ -16,10 +16,71 @@ def feed_result payload: nil, message: nil, ttl_seconds: 600, - cache_key: 'feed_result:test' + cache_key: 'feed_result:test', + error_message: nil ) end + def service_error_result + Html2rss::Web::Feeds::Result.new( + status: :error, + payload: nil, + message: 'Internal Server Error', + ttl_seconds: 600, + cache_key: 'feed_result:error', + error_message: 'upstream timeout' + ) + end + + def json_feed_service_error_tuple(token) + allow(Html2rss::Web::Feeds::Service).to receive(:call).and_return(service_error_result) + get "/api/v1/feeds/#{token}.json" + + [ + last_response.status, + last_response.content_type, + last_response.headers['Cache-Control'], + JSON.parse(last_response.body).fetch('title') + ] + end + + def ghost_feed_token + Html2rss::Web::FeedToken + .create_with_validation( + username: 'ghost', + url: feed_url, + strategy: 'ssrf_filter', + secret_key: ENV.fetch('HTML2RSS_SECRET_KEY') + ) + .encode + end + + def valid_feed_token + Html2rss::Web::Auth.generate_feed_token('admin', feed_url, strategy: 'ssrf_filter') + end + + def json_feed_response_for(token) + stub_json_feed_success + get "/api/v1/feeds/#{token}", {}, { 'HTTP_ACCEPT' => 'application/feed+json' } + + json_feed_headers_tuple + end + + def stub_json_feed_success + allow(Html2rss::Web::Feeds::Service).to receive(:call).and_return(feed_result) + allow(Html2rss::Web::Feeds::JsonRenderer).to receive(:call) + .and_return('{"version":"https://jsonfeed.org/version/1.1","items":[]}') + end + + def json_feed_headers_tuple + [ + last_response.status, + last_response.content_type, + last_response.headers['Cache-Control'], + last_response.headers['Vary'] + ] + end + around do |example| ClimateControl.modify(AUTO_SOURCE_ENABLED: 'true') { example.run } end @@ -97,7 +158,7 @@ def feed_result expect(json.dig('data', 'health', 'status')).to eq('healthy') end - it 'returns error when configuration fails', :aggregate_failures do # rubocop:disable RSpec/ExampleLength + it 'returns error when configuration fails', :aggregate_failures do allow(Html2rss::Web::Auth).to receive(:authenticate).and_return({ username: 'health-check' }) allow(Html2rss::Web::LocalConfig).to receive(:yaml).and_raise(StandardError, 'boom') header 'Authorization', "Bearer #{health_token}" @@ -170,24 +231,15 @@ def feed_result stub_const('Html2rss::Feed', Class.new { attr_reader :channel }) end - it 'returns unauthorized when account not found', :aggregate_failures, openapi: false do # rubocop:disable RSpec/ExampleLength - ghost_token = Html2rss::Web::FeedToken - .create_with_validation( - username: 'ghost', - url: feed_url, - strategy: 'ssrf_filter', - secret_key: ENV.fetch('HTML2RSS_SECRET_KEY') - ) - .encode - - get "/api/v1/feeds/#{ghost_token}", {}, { 'HTTP_ACCEPT' => 'application/xml' } + it 'returns unauthorized when account not found', :aggregate_failures, openapi: false do + get "/api/v1/feeds/#{ghost_feed_token}", {}, { 'HTTP_ACCEPT' => 'application/xml' } expect(last_response.status).to eq(401) expect(last_response.content_type).to include('application/xml') expect(last_response.body).to include('Account not found') end - it 'renders feed for a valid token', :aggregate_failures do # rubocop:disable RSpec/ExampleLength + it 'renders feed for a valid token', :aggregate_failures do token = Html2rss::Web::Auth.generate_feed_token('admin', feed_url, strategy: 'ssrf_filter') allow(Html2rss::Web::Feeds::Service).to receive(:call).and_return(feed_result) @@ -199,22 +251,15 @@ def feed_result expect(last_response.content_type).to include('application/xml') end - it 'renders json feed for a valid token when requested through Accept', :aggregate_failures do # rubocop:disable RSpec/ExampleLength - token = Html2rss::Web::Auth.generate_feed_token('admin', feed_url, strategy: 'ssrf_filter') - - allow(Html2rss::Web::Feeds::Service).to receive(:call).and_return(feed_result) - allow(Html2rss::Web::Feeds::JsonRenderer).to receive(:call) - .and_return('{"version":"https://jsonfeed.org/version/1.1","items":[]}') - - get "/api/v1/feeds/#{token}", {}, { 'HTTP_ACCEPT' => 'application/feed+json' } + it 'renders json feed for a valid token when requested through Accept', :aggregate_failures do + status, content_type, cache_control, vary = json_feed_response_for(valid_feed_token) - expect(last_response.status).to eq(200) - expect(last_response.content_type).to include('application/feed+json') - expect(last_response.headers['Cache-Control']).to include('max-age=600') - expect(last_response.headers['Vary']).to include('Accept') + expect([status, content_type]).to eq([200, 'application/feed+json']) + expect(cache_control).to include('max-age=600') + expect(vary).to include('Accept') end - it 'prefers xml when Accept quality outranks json', :aggregate_failures do # rubocop:disable RSpec/ExampleLength + it 'prefers xml when Accept quality outranks json', :aggregate_failures do token = Html2rss::Web::Auth.generate_feed_token('admin', feed_url, strategy: 'ssrf_filter') allow(Html2rss::Web::Feeds::Service).to receive(:call).and_return(feed_result) @@ -226,7 +271,7 @@ def feed_result expect(last_response.content_type).to include('application/xml') end - it 'ignores query param strategy overrides', :aggregate_failures, openapi: false do # rubocop:disable RSpec/ExampleLength + it 'ignores query param strategy overrides', :aggregate_failures, openapi: false do token = Html2rss::Web::Auth.generate_feed_token('admin', feed_url, strategy: 'ssrf_filter') allow(Html2rss::Web::Feeds::Service).to receive(:call).and_return(feed_result) @@ -254,7 +299,7 @@ def feed_result ) end - it 'returns forbidden when auto source is disabled', :aggregate_failures do # rubocop:disable RSpec/ExampleLength + it 'returns forbidden when auto source is disabled', :aggregate_failures do unique_url = "#{feed_url}/disabled" token = Html2rss::Web::Auth.generate_feed_token('admin', unique_url, strategy: 'ssrf_filter') @@ -266,6 +311,15 @@ def feed_result expect(last_response.content_type).to include('application/xml') expect(last_response.body).to include(Html2rss::Web::Api::V1::Contract::MESSAGES[:auto_source_disabled]) end + + it 'returns non-cacheable json feed errors when service generation fails', :aggregate_failures do + token = Html2rss::Web::Auth.generate_feed_token('admin', feed_url, strategy: 'ssrf_filter') + + status, content_type, cache_control, title = json_feed_service_error_tuple(token) + + expect([status, content_type, title]).to eq([500, 'application/feed+json', 'Error']) + expect(cache_control).to include('no-store') + end end describe 'POST /api/v1/feeds', openapi: { @@ -296,7 +350,7 @@ def feed_result status: 401, code: Html2rss::Web::Api::V1::Contract::CODES[:unauthorized] - it 'creates a feed when request is valid', :aggregate_failures do # rubocop:disable RSpec/ExampleLength + it 'creates a feed when request is valid', :aggregate_failures do header 'Authorization', "Bearer #{admin_token}" header 'Content-Type', 'application/json' post '/api/v1/feeds', request_params.to_json @@ -307,7 +361,7 @@ def feed_result expect(last_response.headers['Content-Type']).to include('application/json') end - it 'returns forbidden for authenticated requests when auto source is disabled', :aggregate_failures do # rubocop:disable RSpec/ExampleLength + it 'returns forbidden for authenticated requests when auto source is disabled', :aggregate_failures do header 'Authorization', "Bearer #{admin_token}" header 'Content-Type', 'application/json' diff --git a/spec/html2rss/web/app_integration_spec.rb b/spec/html2rss/web/app_integration_spec.rb index f1361a27..60ad9f88 100644 --- a/spec/html2rss/web/app_integration_spec.rb +++ b/spec/html2rss/web/app_integration_spec.rb @@ -40,7 +40,8 @@ payload: nil, message: nil, ttl_seconds: 600, - cache_key: 'feed_result:test' + cache_key: 'feed_result:test', + error_message: nil ) end @@ -82,7 +83,7 @@ expect(last_response.body).to include('Invalid token') end - it 'renders the XML feed with cache headers', :aggregate_failures do # rubocop:disable RSpec/ExampleLength + it 'renders the XML feed with cache headers', :aggregate_failures do get "/api/v1/feeds/#{feed_token}", {}, { 'HTTP_HOST' => 'localhost:3000', 'HTTP_ACCEPT' => 'application/xml' } expect(last_response.status).to eq(200) @@ -213,7 +214,7 @@ expect(json_body).to include('error' => include('message' => 'Invalid JSON payload')) end - it 'returns bad request when URL is missing', :aggregate_failures do # rubocop:disable RSpec/ExampleLength + it 'returns bad request when URL is missing', :aggregate_failures do allow(Html2rss::Web::Api::V1::Feeds).to receive(:extract_site_title).and_return('Example') post '/api/v1/feeds', request_payload.merge(url: '').to_json, auth_headers @@ -224,7 +225,7 @@ ) end - it 'returns forbidden when URL is not allowed for account', :aggregate_failures do # rubocop:disable RSpec/ExampleLength + it 'returns forbidden when URL is not allowed for account', :aggregate_failures do allow(Html2rss::Web::UrlValidator).to receive(:url_allowed?).and_return(false) post '/api/v1/feeds', request_payload.to_json, auth_headers @@ -244,7 +245,7 @@ ) end - it 'returns error when feed creation fails', :aggregate_failures do # rubocop:disable RSpec/ExampleLength + it 'returns error when feed creation fails', :aggregate_failures do allow(Html2rss::Web::AutoSource).to receive(:create_stable_feed).and_return(nil) post '/api/v1/feeds', request_payload.to_json, auth_headers @@ -255,7 +256,7 @@ ) end - it 'returns created feed metadata', :aggregate_failures do # rubocop:disable RSpec/ExampleLength + it 'returns created feed metadata', :aggregate_failures do post '/api/v1/feeds', request_payload.to_json, auth_headers expect(last_response.status).to eq(201) diff --git a/spec/html2rss/web/app_spec.rb b/spec/html2rss/web/app_spec.rb index 6ab9521d..8453560e 100644 --- a/spec/html2rss/web/app_spec.rb +++ b/spec/html2rss/web/app_spec.rb @@ -34,7 +34,8 @@ def static_feed_result(ttl:) payload: nil, message: nil, ttl_seconds: Html2rss::Web::CacheTtl.seconds_from_minutes(ttl), - cache_key: 'feed_result:spec' + cache_key: 'feed_result:spec', + error_message: nil ) end @@ -44,6 +45,36 @@ def stub_static_renderers(result, rss_body:, json_body:) allow(Html2rss::Web::Feeds::JsonRenderer).to receive(:call).with(result).and_return(json_body) end + def static_service_error_result + Html2rss::Web::Feeds::Result.new( + status: :error, + payload: nil, + message: 'Internal Server Error', + ttl_seconds: 600, + cache_key: 'feed_result:error', + error_message: 'upstream timeout' + ) + end + + def stub_static_service_error(feed_name) + allow(Html2rss::Web::LocalConfig) + .to receive(:find) + .with(feed_name) + .and_return({ channel: { ttl: 180 } }) + allow(Html2rss::Web::Feeds::Service).to receive(:call).and_return(static_service_error_result) + allow(Html2rss::Web::XmlBuilder).to receive(:build_error_feed).and_return('') + end + + def service_error_response_tuple(path) + get path + [ + last_response.status, + last_response.headers['Content-Type'], + last_response.headers['Cache-Control'].split(',').map(&:strip).sort, + last_response.body + ] + end + it { expect(described_class).to be < Roda } context 'with Rack::Test' do @@ -59,7 +90,7 @@ def app = described_class expect(last_response.headers['Strict-Transport-Security']).to include('max-age=31536000') end - it 'serves static feed routes with caching headers', :aggregate_failures do # rubocop:disable RSpec/ExampleLength + it 'serves static feed routes with caching headers', :aggregate_failures do stub_static_feed get '/legacy' @@ -81,7 +112,7 @@ def app = described_class ) end - it 'serves HEAD requests for static feed routes with negotiated headers only', :aggregate_failures do # rubocop:disable RSpec/ExampleLength + it 'serves HEAD requests for static feed routes with negotiated headers only', :aggregate_failures do stub_static_feed head '/legacy' @@ -119,7 +150,15 @@ def app = described_class ) end - it 'hides unexpected internal error details from API responses', :aggregate_failures do # rubocop:disable RSpec/ExampleLength + it 'renders service failures as non-cacheable xml feed errors', :aggregate_failures do + stub_static_service_error('legacy-service-error') + + expect(service_error_response_tuple('/legacy-service-error')).to eq( + [500, 'application/xml', %w[max-age=0 must-revalidate no-cache no-store private], ''] + ) + end + + it 'hides unexpected internal error details from API responses', :aggregate_failures do allow(Html2rss::Web::Routes::ApiV1).to receive(:call).and_raise(StandardError, 'boom') get '/api/v1' diff --git a/spec/html2rss/web/feeds/cache_spec.rb b/spec/html2rss/web/feeds/cache_spec.rb index 42f425cc..f8c64702 100644 --- a/spec/html2rss/web/feeds/cache_spec.rb +++ b/spec/html2rss/web/feeds/cache_spec.rb @@ -9,10 +9,16 @@ let(:result) do Html2rss::Web::Feeds::Result.new( status: :ok, - payload: { feed: Object.new, url: 'https://example.com', strategy: 'ssrf_filter' }, + payload: Html2rss::Web::Feeds::Payload.new( + feed: Object.new, + site_title: 'Example', + url: 'https://example.com', + strategy: 'ssrf_filter' + ), message: nil, ttl_seconds: 60, - cache_key: 'feed_result:test' + cache_key: 'feed_result:test', + error_message: nil ) end diff --git a/spec/html2rss/web/feeds/json_renderer_spec.rb b/spec/html2rss/web/feeds/json_renderer_spec.rb new file mode 100644 index 00000000..33250711 --- /dev/null +++ b/spec/html2rss/web/feeds/json_renderer_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../../../../app' + +RSpec.describe Html2rss::Web::Feeds::JsonRenderer do + subject(:render_empty_feed) { described_class.call(empty_result) } + + let(:payload) do + Html2rss::Web::Feeds::Payload.new( + feed: Object.new, + site_title: 'https://example.com/articles', + url: 'https://example.com/articles', + strategy: 'ssrf_filter' + ) + end + let(:empty_result) do + Html2rss::Web::Feeds::Result.new( + status: :empty, + payload: payload, + message: nil, + ttl_seconds: 600, + cache_key: 'feed_result:test', + error_message: nil + ) + end + + it 'passes the normalized site title into empty-feed rendering' do + allow(Html2rss::Web::JsonFeedBuilder).to receive(:build_empty_feed_warning).and_return('{"items":[]}') + + render_empty_feed + + expect(Html2rss::Web::JsonFeedBuilder).to have_received(:build_empty_feed_warning).with(expected_builder_args) + end + + # @return [Hash{Symbol=>String}] + def expected_builder_args + { + url: 'https://example.com/articles', + strategy: 'ssrf_filter', + site_title: 'https://example.com/articles' + } + end +end diff --git a/spec/html2rss/web/feeds/rss_renderer_spec.rb b/spec/html2rss/web/feeds/rss_renderer_spec.rb new file mode 100644 index 00000000..58aaf4c0 --- /dev/null +++ b/spec/html2rss/web/feeds/rss_renderer_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../../../../app' + +RSpec.describe Html2rss::Web::Feeds::RssRenderer do + subject(:render_empty_feed) { described_class.call(empty_result) } + + let(:payload) do + Html2rss::Web::Feeds::Payload.new( + feed: Object.new, + site_title: 'https://example.com/articles', + url: 'https://example.com/articles', + strategy: 'ssrf_filter' + ) + end + let(:empty_result) do + Html2rss::Web::Feeds::Result.new( + status: :empty, + payload: payload, + message: nil, + ttl_seconds: 600, + cache_key: 'feed_result:test', + error_message: nil + ) + end + + it 'passes the normalized site title into empty-feed rendering' do + allow(Html2rss::Web::XmlBuilder).to receive(:build_empty_feed_warning).and_return('') + + render_empty_feed + + expect(Html2rss::Web::XmlBuilder).to have_received(:build_empty_feed_warning).with(expected_builder_args) + end + + # @return [Hash{Symbol=>String}] + def expected_builder_args + { + url: 'https://example.com/articles', + strategy: 'ssrf_filter', + site_title: 'https://example.com/articles' + } + end +end diff --git a/spec/html2rss/web/feeds/service_spec.rb b/spec/html2rss/web/feeds/service_spec.rb index 7c96c6f0..ebb84516 100644 --- a/spec/html2rss/web/feeds/service_spec.rb +++ b/spec/html2rss/web/feeds/service_spec.rb @@ -24,7 +24,8 @@ end context 'when feed generation succeeds with items' do - let(:feed) { Struct.new(:items).new([Object.new]) } + let(:channel) { Struct.new(:title).new('Example Feed') } + let(:feed) { Struct.new(:items, :channel).new([Object.new], channel) } before do allow(Html2rss).to receive(:feed).with(resolved_source.generator_input).and_return(feed) @@ -42,12 +43,8 @@ expect(result.cache_key).to eq('feed_result:example-feed:abc123') end - it 'retains the canonical payload metadata' do - expect(result.payload).to eq( - feed: feed, - url: 'https://example.com/articles', - strategy: 'ssrf_filter' - ) + it 'retains the normalized payload object' do + expect(result.payload).to eq(expected_payload) end it 'reuses the cached result for repeated requests' do @@ -60,7 +57,8 @@ context 'when the generated feed has no items' do before do - allow(Html2rss).to receive(:feed).with(resolved_source.generator_input).and_return(Struct.new(:items).new([])) + feed = Struct.new(:items, :channel).new([], Struct.new(:title).new(nil)) + allow(Html2rss).to receive(:feed).with(resolved_source.generator_input).and_return(feed) end it 'marks the result as empty' do @@ -70,6 +68,10 @@ it 'keeps the result message empty' do expect(result.message).to be_nil end + + it 'normalizes a fallback site title from the source url' do + expect(result.payload.site_title).to eq('https://example.com/articles') + end end context 'when generation fails' do @@ -81,12 +83,33 @@ expect(result.status).to eq(:error) end - it 'returns the generator error message' do - expect(result.message).to eq('boom') + it 'returns a generic client error message' do + expect(result.message).to eq('Internal Server Error') + end + + it 'retains the internal error details for observability' do + expect(result.error_message).to eq('boom') end it 'drops the feed payload' do expect(result.payload).to be_nil end + + it 'does not cache the failure result' do + described_class.call(resolved_source) + described_class.call(resolved_source) + + expect(Html2rss).to have_received(:feed).twice + end + end + + # @return [Html2rss::Web::Feeds::Payload] + def expected_payload + Html2rss::Web::Feeds::Payload.new( + feed: feed, + site_title: 'Example Feed', + url: 'https://example.com/articles', + strategy: 'ssrf_filter' + ) end end diff --git a/spec/html2rss/web/http/feed_response_spec.rb b/spec/html2rss/web/http/feed_response_spec.rb new file mode 100644 index 00000000..4e567b2e --- /dev/null +++ b/spec/html2rss/web/http/feed_response_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../../../../app' + +RSpec.describe Html2rss::Web::Http::FeedResponse do + let(:response) { Rack::Response.new } + + context 'with a cacheable success result' do + subject(:write_response) do + described_class.call( + response: response, + representation: Html2rss::Web::Feeds::ResponseFormat::RSS, + result: result + ) + end + + let(:result) do + Html2rss::Web::Feeds::Result.new( + status: :ok, + payload: nil, + message: nil, + ttl_seconds: 600, + cache_key: 'feed_result:test', + error_message: nil + ) + end + + before do + allow(Html2rss::Web::Feeds::RssRenderer).to receive(:call).with(result).and_return('') + end + + it 'writes the expected response tuple' do + expect(response_tuple(write_response)).to eq([200, 'application/xml', '']) + end + + it 'marks the response as cacheable' do + write_response + + expect(response['Cache-Control']).to include('public') + end + end + + context 'with an error result' do + subject(:write_response) do + described_class.call( + response: response, + representation: Html2rss::Web::Feeds::ResponseFormat::JSON_FEED, + result: result + ) + end + + let(:result) do + Html2rss::Web::Feeds::Result.new( + status: :error, + payload: nil, + message: 'Internal Server Error', + ttl_seconds: 600, + cache_key: 'feed_result:error', + error_message: 'timeout' + ) + end + + before do + allow(Html2rss::Web::Feeds::JsonRenderer).to receive(:call).with(result).and_return('{"title":"Error"}') + end + + it 'writes the expected response tuple' do + expect(response_tuple(write_response)).to eq([500, 'application/feed+json', '{"title":"Error"}']) + end + + it 'marks the response as non-cacheable' do + write_response + + expect(response['Cache-Control']).to include('no-store') + end + end + + private + + # @param body [String] + # @return [Array<(Integer, String, String)>] + def response_tuple(body) + [response.status, response['Content-Type'], body] + end +end diff --git a/spec/support/openapi.rb b/spec/support/openapi.rb index ba927eac..0f1d980f 100644 --- a/spec/support/openapi.rb +++ b/spec/support/openapi.rb @@ -58,6 +58,17 @@ end end + deep_sort = lambda do |value| + case value + when Hash + value.keys.sort_by(&:to_s).to_h { |key| [key, deep_sort.call(value[key])] } + when Array + value.map { |item| deep_sort.call(item) } + else + value + end + end + path_map = spec['paths'] || spec[:paths] next unless path_map.is_a?(Hash) @@ -82,9 +93,9 @@ merged['responses'] = (existing['responses'] || {}).merge(operation_doc['responses'] || {}) merged['parameters'] = [*(existing['parameters'] || []), *(operation_doc['parameters'] || [])] merged['parameters'].uniq! { |parameter| [parameter['name'], parameter['in']] } - normalized_paths[normalized][verb] = merged + normalized_paths[normalized][verb] = deep_sort.call(merged) else - normalized_paths[normalized][verb] = operation_doc + normalized_paths[normalized][verb] = deep_sort.call(operation_doc) end normalized_paths[normalized][verb]['description'] ||= normalized_paths[normalized][verb]['summary'] @@ -107,9 +118,9 @@ end if spec.key?('paths') - spec['paths'] = normalized_paths + spec['paths'] = deep_sort.call(normalized_paths) else - spec[:paths] = normalized_paths + spec[:paths] = deep_sort.call(normalized_paths) end tags = [ From 16d4c6fc2a1d307244fb8702c0f2052aa1a3acce Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sat, 14 Mar 2026 16:19:48 +0100 Subject: [PATCH 08/11] style: relax rubocop --- .rubocop.yml | 12 ++++++++++++ spec/html2rss/web/account_manager_spec.rb | 4 ++-- spec/html2rss/web/feeds/resolver_spec.rb | 2 +- spec/smoke/docker_spec.rb | 12 ++++++------ 4 files changed, 21 insertions(+), 9 deletions(-) diff --git a/.rubocop.yml b/.rubocop.yml index bce5da80..b9821c39 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -12,6 +12,9 @@ AllCops: - '**/*.yaml' - '**/.tool-versions' +Layout/LineLength: + Max: 120 + Metrics/BlockLength: Exclude: - Rakefile @@ -33,3 +36,12 @@ RSpec/SpecFilePathFormat: Exclude: - 'spec/html2rss/web/app/*_spec.rb' - 'spec/html2rss/web/app/health_check/auth_spec.rb' + +RSpec/DescribeClass: + Enabled: false + +RSpec/ExampleLength: + Max: 12 + +RSpec/MultipleExpectations: + Max: 2 diff --git a/spec/html2rss/web/account_manager_spec.rb b/spec/html2rss/web/account_manager_spec.rb index d122c86c..eeab3939 100644 --- a/spec/html2rss/web/account_manager_spec.rb +++ b/spec/html2rss/web/account_manager_spec.rb @@ -18,7 +18,7 @@ end describe '.reload!' do - it 'keeps memoized snapshot before reload' do # rubocop:disable RSpec/ExampleLength + it 'keeps memoized snapshot before reload' do allow(Html2rss::Web::LocalConfig).to receive(:global).and_return( { auth: { accounts: [{ username: 'alice', token: 'token-1', allowed_urls: ['*'] }] } }, { auth: { accounts: [{ username: 'bob', token: 'token-2', allowed_urls: ['*'] }] } } @@ -28,7 +28,7 @@ expect(described_class.get_account('token-2')).to be_nil end - it 'clears memoized snapshot after reload' do # rubocop:disable RSpec/ExampleLength + it 'clears memoized snapshot after reload' do allow(Html2rss::Web::LocalConfig).to receive(:global).and_return( { auth: { accounts: [{ username: 'alice', token: 'token-1', allowed_urls: ['*'] }] } }, { auth: { accounts: [{ username: 'bob', token: 'token-2', allowed_urls: ['*'] }] } } diff --git a/spec/html2rss/web/feeds/resolver_spec.rb b/spec/html2rss/web/feeds/resolver_spec.rb index fb002a1a..ec12f17f 100644 --- a/spec/html2rss/web/feeds/resolver_spec.rb +++ b/spec/html2rss/web/feeds/resolver_spec.rb @@ -30,7 +30,7 @@ def resolved_tuple(resolved) allow(Html2rss::RequestService).to receive(:default_strategy_name).and_return(:ssrf_filter) end - it 'normalizes the static source into shared generator input', :aggregate_failures do # rubocop:disable RSpec/ExampleLength + it 'normalizes the static source into shared generator input', :aggregate_failures do resolved = described_class.call(feed_request) expect(resolved_tuple(resolved)).to match( diff --git a/spec/smoke/docker_spec.rb b/spec/smoke/docker_spec.rb index 5f183eaa..0ba839fa 100644 --- a/spec/smoke/docker_spec.rb +++ b/spec/smoke/docker_spec.rb @@ -4,7 +4,7 @@ require 'json' require 'uri' -RSpec.describe 'Dockerized API smoke test', :docker do # rubocop:disable RSpec/DescribeClass +RSpec.describe 'Dockerized API smoke test', :docker do let(:base_url) { ENV.fetch('SMOKE_BASE_URL', 'http://127.0.0.1:4000') } let(:health_token) { ENV.fetch('SMOKE_HEALTH_TOKEN', 'CHANGE_ME_HEALTH_CHECK_TOKEN') } let(:feed_token) { ENV.fetch('SMOKE_API_TOKEN', 'CHANGE_ME_ADMIN_TOKEN') } @@ -28,7 +28,7 @@ def perform_request(uri, request) [response, response.body.to_s.empty? ? {} : JSON.parse(response.body)] end - it 'exposes health endpoints without authentication requirements', :aggregate_failures do # rubocop:disable RSpec/ExampleLength + it 'exposes health endpoints without authentication requirements', :aggregate_failures do response, payload = get_json('/api/v1/health/ready') expect(response).to be_a(Net::HTTPOK) expect(payload.fetch('success')).to be(true) @@ -38,7 +38,7 @@ def perform_request(uri, request) expect(payload.fetch('success')).to be(true) end - it 'requires authentication for the secure health endpoint', :aggregate_failures do # rubocop:disable RSpec/ExampleLength + it 'requires authentication for the secure health endpoint', :aggregate_failures do response, payload = get_json('/api/v1/health') expect(response).to be_a(Net::HTTPUnauthorized) expect(payload.dig('error', 'code')).to eq('UNAUTHORIZED') @@ -48,7 +48,7 @@ def perform_request(uri, request) expect(payload.dig('data', 'health', 'status')).to eq('healthy') end - it 'creates a feed when provided with valid credentials', :aggregate_failures do # rubocop:disable RSpec/ExampleLength + it 'creates a feed when provided with valid credentials', :aggregate_failures do payload = { url: 'https://example.com/articles', strategy: 'ssrf_filter' @@ -59,7 +59,7 @@ def perform_request(uri, request) expect(body.dig('error', 'code')).to eq('UNAUTHORIZED') end - it 'creates feed when auto source is enabled', :aggregate_failures do # rubocop:disable RSpec/ExampleLength + it 'creates feed when auto source is enabled', :aggregate_failures do next unless auto_source_enabled payload = { @@ -76,7 +76,7 @@ def perform_request(uri, request) expect(body.dig('data', 'feed', 'public_url')).to match(%r{^/api/v1/feeds/}) end - it 'returns forbidden for authenticated creation when auto source is disabled', :aggregate_failures do # rubocop:disable RSpec/ExampleLength + it 'returns forbidden for authenticated creation when auto source is disabled', :aggregate_failures do next if auto_source_enabled payload = { From 12b40b20d4cc124ce8ed97ecb34f065ffd7653f8 Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sat, 14 Mar 2026 16:19:57 +0100 Subject: [PATCH 09/11] chore: update bundler deps --- Gemfile.lock | 358 ++++++++++++++++++++++++++++++++------------------- 1 file changed, 226 insertions(+), 132 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index a893a538..f8d2a65b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,13 +1,15 @@ GIT remote: https://github.com/html2rss/html2rss - revision: 29660a74c2ac9330f60a99a1670241dde32a7222 + revision: e0dca5bf74b17c1e2a0618fc0a4af27c16da1883 branch: master specs: html2rss (0.17.0) addressable (~> 2.7) + brotli dry-validation faraday (> 2.0.1, < 3.0) faraday-follow_redirects + faraday-gzip (~> 3) kramdown mime-types (> 3.0) nokogiri (>= 1.10, < 2.0) @@ -23,7 +25,7 @@ GIT GIT remote: https://github.com/html2rss/html2rss-configs - revision: d6d45eea7005decad6007b56f838d42141b6ec96 + revision: 4e401e6ed97f5e28da07978431500d7c39de8a41 specs: html2rss-configs (0.2.0) html2rss @@ -60,17 +62,47 @@ GEM securerandom (>= 0.3) tzinfo (~> 2.0, >= 2.0.5) uri (>= 0.13.1) - addressable (2.8.7) - public_suffix (>= 2.0.2, < 7.0) + addressable (2.8.9) + public_suffix (>= 2.0.2, < 8.0) ast (2.4.3) + async (2.38.0) + console (~> 1.29) + fiber-annotation + io-event (~> 1.11) + metrics (~> 0.12) + traces (~> 0.18) + async-http (0.94.2) + async (>= 2.10.2) + async-pool (~> 0.11) + io-endpoint (~> 0.14) + io-stream (~> 0.6) + metrics (~> 0.12) + protocol-http (~> 0.58) + protocol-http1 (~> 0.36) + protocol-http2 (~> 0.22) + protocol-url (~> 0.2) + traces (~> 0.10) + async-pool (0.11.2) + async (>= 2.0) + async-websocket (0.30.0) + async-http (~> 0.76) + protocol-http (~> 0.34) + protocol-rack (~> 0.7) + protocol-websocket (~> 0.17) base64 (0.3.0) - bigdecimal (3.2.3) + bigdecimal (4.0.1) + brotli (0.8.0) builder (3.3.0) - byebug (12.0.0) + byebug (13.0.0) + reline (>= 0.6.0) climate_control (1.2.0) - concurrent-ruby (1.3.5) + concurrent-ruby (1.3.6) connection_pool (3.0.2) - crack (1.0.0) + console (1.34.3) + fiber-annotation + fiber-local (~> 1.1) + json + crack (1.0.1) bigdecimal rexml crass (1.0.6) @@ -80,27 +112,27 @@ GEM dry-configurable (1.3.0) dry-core (~> 1.1) zeitwerk (~> 2.6) - dry-core (1.1.0) + dry-core (1.2.0) concurrent-ruby (~> 1.0) logger zeitwerk (~> 2.6) - dry-inflector (1.2.0) + dry-inflector (1.3.1) dry-initializer (3.2.0) dry-logic (1.6.0) bigdecimal concurrent-ruby (~> 1.0) dry-core (~> 1.1) zeitwerk (~> 2.6) - dry-schema (1.14.1) + dry-schema (1.16.0) concurrent-ruby (~> 1.0) dry-configurable (~> 1.0, >= 1.0.1) dry-core (~> 1.1) dry-initializer (~> 3.2) - dry-logic (~> 1.5) - dry-types (~> 1.8) + dry-logic (~> 1.6) + dry-types (~> 1.9, >= 1.9.1) zeitwerk (~> 2.6) - dry-types (1.8.3) - bigdecimal (~> 3.0) + dry-types (1.9.1) + bigdecimal (>= 3.0) concurrent-ruby (~> 1.0) dry-core (~> 1.0) dry-inflector (~> 1.0) @@ -113,66 +145,99 @@ GEM dry-schema (~> 1.14) zeitwerk (~> 2.6) erubi (1.13.1) - faraday (2.13.4) + faraday (2.14.1) faraday-net_http (>= 2.0, < 3.5) json logger - faraday-follow_redirects (0.3.0) + faraday-follow_redirects (0.5.0) faraday (>= 1, < 3) - faraday-net_http (3.4.1) - net-http (>= 0.5.0) + faraday-gzip (3.1.0) + faraday (>= 2.0, < 3) + zlib (~> 3.0) + faraday-net_http (3.4.2) + net-http (~> 0.5) + fiber-annotation (0.2.0) + fiber-local (1.1.0) + fiber-storage + fiber-storage (1.0.1) hashdiff (1.2.1) i18n (1.14.8) concurrent-ruby (~> 1.0) - json (2.15.0) - kramdown (2.5.1) - rexml (>= 3.3.9) + io-console (0.8.2) + io-endpoint (0.17.2) + io-event (1.14.4) + io-stream (0.11.1) + json (2.19.1) + json-schema (6.2.0) + addressable (~> 2.8) + bigdecimal (>= 3.1, < 5) + kramdown (2.5.2) + rexml (>= 3.4.4) language_server-protocol (3.17.0.5) lint_roller (1.1.0) logger (1.7.0) loofah (2.25.0) crass (~> 1.0.2) nokogiri (>= 1.12.0) + mcp (0.8.0) + json-schema (>= 4.1) + metrics (0.15.0) mime-types (3.7.0) logger mime-types-data (~> 3.2025, >= 3.2025.0507) - mime-types-data (3.2025.0924) + mime-types-data (3.2026.0303) minitest (6.0.2) drb (~> 2.0) prism (~> 1.5) - net-http (0.6.0) - uri - nio4r (2.7.4) - nokogiri (1.18.10-aarch64-linux-gnu) + net-http (0.9.1) + uri (>= 0.11.1) + nio4r (2.7.5) + nokogiri (1.19.1-aarch64-linux-gnu) racc (~> 1.4) - nokogiri (1.18.10-aarch64-linux-musl) + nokogiri (1.19.1-aarch64-linux-musl) racc (~> 1.4) - nokogiri (1.18.10-arm-linux-gnu) + nokogiri (1.19.1-arm-linux-gnu) racc (~> 1.4) - nokogiri (1.18.10-arm-linux-musl) + nokogiri (1.19.1-arm-linux-musl) racc (~> 1.4) - nokogiri (1.18.10-arm64-darwin) + nokogiri (1.19.1-arm64-darwin) racc (~> 1.4) - nokogiri (1.18.10-x86_64-darwin) + nokogiri (1.19.1-x86_64-darwin) racc (~> 1.4) - nokogiri (1.18.10-x86_64-linux-gnu) + nokogiri (1.19.1-x86_64-linux-gnu) racc (~> 1.4) - nokogiri (1.18.10-x86_64-linux-musl) + nokogiri (1.19.1-x86_64-linux-musl) racc (~> 1.4) parallel (1.27.0) - parser (3.3.9.0) + parser (3.3.10.2) ast (~> 2.4.1) racc - prism (1.5.1) - public_suffix (6.0.2) - puma (7.0.4) + prism (1.9.0) + protocol-hpack (1.5.1) + protocol-http (0.60.0) + protocol-http1 (0.37.0) + protocol-http (~> 0.58) + protocol-http2 (0.24.0) + protocol-hpack (~> 1.4) + protocol-http (~> 0.47) + protocol-rack (0.21.1) + io-stream (>= 0.10) + protocol-http (~> 0.58) + rack (>= 1.0) + protocol-url (0.4.0) + protocol-websocket (0.20.2) + protocol-http (~> 0.2) + public_suffix (7.0.5) + puma (7.2.0) nio4r (~> 2.0) - puppeteer-ruby (0.45.6) - concurrent-ruby (>= 1.1, < 1.4) + puppeteer-ruby (0.51.0) + async (>= 2.35.1, < 3.0) + async-http (>= 0.60, < 1.0) + async-websocket (>= 0.27, < 1.0) + base64 mime-types (>= 3.0) - websocket-driver (>= 0.6.0) racc (1.8.1) - rack (3.2.1) + rack (3.2.5) rack-cache (1.17.0) rack (>= 0.4) rack-session (2.1.1) @@ -190,63 +255,67 @@ GEM loofah (~> 2.25) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) rainbow (3.1.1) - rake (13.3.0) - rbs (3.9.5) + rake (13.3.1) + rbs (3.10.3) logger + tsort regexp_parser (2.11.3) - reverse_markdown (3.0.0) + reline (0.6.3) + io-console (~> 0.5) + reverse_markdown (3.0.2) nokogiri rexml (3.4.4) - roda (3.96.0) + roda (3.102.0) rack - rspec (3.13.1) + rspec (3.13.2) rspec-core (~> 3.13.0) rspec-expectations (~> 3.13.0) rspec-mocks (~> 3.13.0) - rspec-core (3.13.5) + rspec-core (3.13.6) rspec-support (~> 3.13.0) rspec-expectations (3.13.5) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-mocks (3.13.5) + rspec-mocks (3.13.8) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-openapi (0.24.0) + rspec-openapi (0.25.0) actionpack (>= 5.2.0) rails-dom-testing rspec-core - rspec-support (3.13.6) - rss (0.3.1) + rspec-support (3.13.7) + rss (0.3.2) rexml - rubocop (1.81.1) + rubocop (1.85.1) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) + mcp (~> 0.6) parallel (~> 1.10) parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 2.9.3, < 3.0) - rubocop-ast (>= 1.47.1, < 2.0) + rubocop-ast (>= 1.49.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) - rubocop-ast (1.47.1) + rubocop-ast (1.49.1) parser (>= 3.3.7.2) - prism (~> 1.4) - rubocop-performance (1.26.0) + prism (~> 1.7) + rubocop-performance (1.26.1) lint_roller (~> 1.1) rubocop (>= 1.75.0, < 2.0) - rubocop-ast (>= 1.44.0, < 2.0) + rubocop-ast (>= 1.47.1, < 2.0) rubocop-rake (0.7.1) lint_roller (~> 1.1) rubocop (>= 1.72.1) - rubocop-rspec (3.7.0) + rubocop-rspec (3.9.0) lint_roller (~> 1.1) - rubocop (~> 1.72, >= 1.72.1) + rubocop (~> 1.81) rubocop-thread_safety (0.7.3) lint_roller (~> 1.1) rubocop (~> 1.72, >= 1.72.1) rubocop-ast (>= 1.44.0, < 2.0) - ruby-lsp (0.26.1) + ruby-lsp (0.26.8) language_server-protocol (~> 3.17.0) prism (>= 1.2, < 2.0) rbs (>= 3, < 5) @@ -255,9 +324,10 @@ GEM crass (~> 1.0.2) nokogiri (>= 1.16.8) securerandom (0.4.1) - sentry-ruby (5.28.0) + sentry-ruby (6.4.1) bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) + logger simplecov (0.22.0) docile (~> 1.1) simplecov-html (~> 0.11) @@ -265,27 +335,25 @@ GEM simplecov-html (0.13.2) simplecov_json_formatter (0.1.4) ssrf_filter (1.3.0) - stackprof (0.2.27) - thor (1.4.0) + stackprof (0.2.28) + thor (1.5.0) + traces (0.18.2) + tsort (0.2.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) unicode-display_width (3.2.0) unicode-emoji (~> 4.1) - unicode-emoji (4.1.0) - uri (1.0.3) + unicode-emoji (4.2.0) + uri (1.1.1) useragent (0.16.11) - vcr (6.3.1) - base64 - webmock (3.25.1) + vcr (6.4.0) + webmock (3.26.1) addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) - websocket-driver (0.8.0) - base64 - websocket-extensions (>= 0.1.0) - websocket-extensions (0.1.5) - yard (0.9.37) - zeitwerk (2.7.3) + yard (0.9.38) + zeitwerk (2.7.5) + zlib (3.2.3) PLATFORMS aarch64-linux-gnu @@ -330,63 +398,87 @@ CHECKSUMS actionpack (8.1.2) sha256=ced74147a1f0daafaa4bab7f677513fd4d3add574c7839958f7b4f1de44f8423 actionview (8.1.2) sha256=80455b2588911c9b72cec22d240edacb7c150e800ef2234821269b2b2c3e2e5b activesupport (8.1.2) sha256=88842578ccd0d40f658289b0e8c842acfe9af751afee2e0744a7873f50b6fdae - addressable (2.8.7) sha256=462986537cf3735ab5f3c0f557f14155d778f4b43ea4f485a9deb9c8f7c58232 + addressable (2.8.9) sha256=cc154fcbe689711808a43601dee7b980238ce54368d23e127421753e46895485 ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 + async (2.38.0) sha256=f95d00da2eb72e2c5340a6d78c321ec70cec65cbeceb0dc2cb2a32ff17a0f4cf + async-http (0.94.2) sha256=c5ca94b337976578904a373833abe5b8dfb466a2946af75c4ae38c409c5c78b2 + async-pool (0.11.2) sha256=0a43a17b02b04d9c451b7d12fafa9a50e55dc6dd00d4369aca00433f16a7e3ed + async-websocket (0.30.0) sha256=55739954528ad8f87f7792d0452e1268d1ef2aa5b3719f79400a05a1a6202cdf base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b - bigdecimal (3.2.3) sha256=ffd11d1ac67a0d3b2f44aec0a6487210b3f813f363dd11f1fcccf5ba00da4e1b + bigdecimal (4.0.1) sha256=8b07d3d065a9f921c80ceaea7c9d4ae596697295b584c296fe599dd0ad01c4a7 + brotli (0.8.0) sha256=0c5a42046b3b603fb109656881147fd76064c034b7d19c1b4fcc32a093a4d55d builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f - byebug (12.0.0) sha256=d4a150d291cca40b66ec9ca31f754e93fed8aa266a17335f71bb0afa7fca1a1e + byebug (13.0.0) sha256=d2263efe751941ca520fa29744b71972d39cbc41839496706f5d9b22e92ae05d climate_control (1.2.0) sha256=36b21896193fa8c8536fa1cd843a07cf8ddbd03aaba43665e26c53ec1bd70aa5 - concurrent-ruby (1.3.5) sha256=813b3e37aca6df2a21a3b9f1d497f8cbab24a2b94cab325bffe65ee0f6cbebc6 + concurrent-ruby (1.3.6) sha256=6b56837e1e7e5292f9864f34b69c5a2cbc75c0cf5338f1ce9903d10fa762d5ab connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a - crack (1.0.0) sha256=c83aefdb428cdc7b66c7f287e488c796f055c0839e6e545fec2c7047743c4a49 + console (1.34.3) sha256=869fbd74697efc4c606f102d2812b0b008e4e7fd738a91c591e8577140ec0dcc + crack (1.0.1) sha256=ff4a10390cd31d66440b7524eb1841874db86201d5b70032028553130b6d4c7e crass (1.0.6) sha256=dc516022a56e7b3b156099abc81b6d2b08ea1ed12676ac7a5657617f012bd45d diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962 docile (1.4.1) sha256=96159be799bfa73cdb721b840e9802126e4e03dfc26863db73647204c727f21e drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 dry-configurable (1.3.0) sha256=882d862858567fc1210d2549d4c090f34370fc1bb7c5c1933de3fe792e18afa8 - dry-core (1.1.0) sha256=0903821a9707649a7da545a2cd88e20f3a663ab1c5288abd7f914fa7751ab195 - dry-inflector (1.2.0) sha256=22f5d0b50fd57074ae57e2ca17e3b300e57564c218269dcf82ff3e42d3f38f2e + dry-core (1.2.0) sha256=0cc5a7da88df397f153947eeeae42e876e999c1e30900f3c536fb173854e96a1 + dry-inflector (1.3.1) sha256=7fb0c2bb04f67638f25c52e7ba39ab435d922a3a5c3cd196120f63accb682dcc dry-initializer (3.2.0) sha256=37d59798f912dc0a1efe14a4db4a9306989007b302dcd5f25d0a2a20c166c4e3 dry-logic (1.6.0) sha256=da6fedbc0f90fc41f9b0cc7e6f05f5d529d1efaef6c8dcc8e0733f685745cea2 - dry-schema (1.14.1) sha256=2fcd7539a7099cacae6a22f6a3a2c1846fe5afeb1c841cde432c89c6cb9b9ff1 - dry-types (1.8.3) sha256=b5d97a45e0ed273131c0c3d5bc9f5633c2d1242e092ee47401ce7d5eab65c1bc + dry-schema (1.16.0) sha256=cd3aaeabc0f1af66ec82a29096d4c4fb92a0a58b9dae29a22b1bbceb78985727 + dry-types (1.9.1) sha256=baebeecdb9f8395d6c9d227b62011279440943e3ef2468fe8ccc1ba11467f178 dry-validation (1.11.1) sha256=70900bb5a2d911c8aab566d3e360c6bff389b8bf92ea8e04885ce51c41ff8085 erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9 - faraday (2.13.4) sha256=c719ff52cfd0dbaeca79dd83ed3aeea3f621032abf8bc959d1c05666157cac26 - faraday-follow_redirects (0.3.0) sha256=d92d975635e2c7fe525dd494fcd4b9bb7f0a4a0ec0d5f4c15c729530fdb807f9 - faraday-net_http (3.4.1) sha256=095757fae7872b94eac839c08a1a4b8d84fd91d6886cfbe75caa2143de64ab3b + faraday (2.14.1) sha256=a43cceedc1e39d188f4d2cdd360a8aaa6a11da0c407052e426ba8d3fb42ef61c + faraday-follow_redirects (0.5.0) sha256=5cde93c894b30943a5d2b93c2fe9284216a6b756f7af406a1e55f211d97d10ad + faraday-gzip (3.1.0) sha256=320783690be169f9b7ddde11598b77156951343753f66a9ab98b1f6694433ff8 + faraday-net_http (3.4.2) sha256=f147758260d3526939bf57ecf911682f94926a3666502e24c69992765875906c + fiber-annotation (0.2.0) sha256=7abfadf1d119f508867d4103bf231c0354d019cc39a5738945dec2edadaf6c03 + fiber-local (1.1.0) sha256=c885f94f210fb9b05737de65d511136ea602e00c5105953748aa0f8793489f06 + fiber-storage (1.0.1) sha256=f48e5b6d8b0be96dac486332b55cee82240057065dc761c1ea692b2e719240e1 hashdiff (1.2.1) sha256=9c079dbc513dfc8833ab59c0c2d8f230fa28499cc5efb4b8dd276cf931457cd1 html2rss (0.17.0) html2rss-configs (0.2.0) i18n (1.14.8) sha256=285778639134865c5e0f6269e0b818256017e8cde89993fdfcbfb64d088824a5 - json (2.15.0) sha256=bc24d490a1d81bcbf6b45ee5c02695545d4ed37f679cee879b789a2bbb53ad5c - kramdown (2.5.1) sha256=87bbb6abd9d3cebe4fc1f33e367c392b4500e6f8fa19dd61c0972cf4afe7368c + io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc + io-endpoint (0.17.2) sha256=3feaf766c116b35839c11fac68b6aaadc47887bb488902a57bf8e1d288fb3338 + io-event (1.14.4) sha256=455a9e4fb4613d12867b90461c297af6993b400a521bf62046f83b27f9c6aa3d + io-stream (0.11.1) sha256=fa5f551fcff99581c1757b9d1cee2c37b124f07d2ca4f40b756a05ab9bd21b87 + json (2.19.1) sha256=dd94fdc59e48bff85913829a32350b3148156bc4fd2a95a2568a78b11344082d + json-schema (6.2.0) sha256=e8bff46ed845a22c1ab2bd0d7eccf831c01fe23bb3920caa4c74db4306813666 + kramdown (2.5.2) sha256=1ba542204c66b6f9111ff00dcc26075b95b220b07f2905d8261740c82f7f02fa language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 loofah (2.25.0) sha256=df5ed7ac3bac6a4ec802df3877ee5cc86d027299f8952e6243b3dac446b060e6 + mcp (0.8.0) sha256=ae8bd146bb8e168852866fd26f805f52744f6326afb3211e073f78a95e0c34fb + metrics (0.15.0) sha256=61ded5bac95118e995b1bc9ed4a5f19bc9814928a312a85b200abbdac9039072 mime-types (3.7.0) sha256=dcebf61c246f08e15a4de34e386ebe8233791e868564a470c3fe77c00eed5e56 - mime-types-data (3.2025.0924) sha256=f276bca15e59f35767cbcf2bc10e023e9200b30bd6a572c1daf7f4cc24994728 + mime-types-data (3.2026.0303) sha256=164af1de5824c5195d4b503b0a62062383b65c08671c792412450cd22d3bc224 minitest (6.0.2) sha256=db6e57956f6ecc6134683b4c87467d6dd792323c7f0eea7b93f66bd284adbc3d - net-http (0.6.0) sha256=9621b20c137898af9d890556848c93603716cab516dc2c89b01a38b894e259fb - nio4r (2.7.4) sha256=d95dee68e0bb251b8ff90ac3423a511e3b784124e5db7ff5f4813a220ae73ca9 - nokogiri (1.18.10-aarch64-linux-gnu) sha256=7fb87235d729c74a2be635376d82b1d459230cc17c50300f8e4fcaabc6195344 - nokogiri (1.18.10-aarch64-linux-musl) sha256=7e74e58314297cc8a8f1b533f7212d1999dbe2639a9ee6d97b483ea2acc18944 - nokogiri (1.18.10-arm-linux-gnu) sha256=51f4f25ab5d5ba1012d6b16aad96b840a10b067b93f35af6a55a2c104a7ee322 - nokogiri (1.18.10-arm-linux-musl) sha256=1c6ea754e51cecc85c30ee8ab1e6aa4ce6b6e134d01717e9290e79374a9e00aa - nokogiri (1.18.10-arm64-darwin) sha256=c2b0de30770f50b92c9323fa34a4e1cf5a0af322afcacd239cd66ee1c1b22c85 - nokogiri (1.18.10-x86_64-darwin) sha256=536e74bed6db2b5076769cab5e5f5af0cd1dccbbd75f1b3e1fa69d1f5c2d79e2 - nokogiri (1.18.10-x86_64-linux-gnu) sha256=ff5ba26ba2dbce5c04b9ea200777fd225061d7a3930548806f31db907e500f72 - nokogiri (1.18.10-x86_64-linux-musl) sha256=0651fccf8c2ebbc2475c8b1dfd7ccac3a0a6d09f8a41b72db8c21808cb483385 + net-http (0.9.1) sha256=25ba0b67c63e89df626ed8fac771d0ad24ad151a858af2cc8e6a716ca4336996 + nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1 + nokogiri (1.19.1-aarch64-linux-gnu) sha256=cfdb0eafd9a554a88f12ebcc688d2b9005f9fce42b00b970e3dc199587b27f32 + nokogiri (1.19.1-aarch64-linux-musl) sha256=1e2150ab43c3b373aba76cd1190af7b9e92103564063e48c474f7600923620b5 + nokogiri (1.19.1-arm-linux-gnu) sha256=0a39ed59abe3bf279fab9dd4c6db6fe8af01af0608f6e1f08b8ffa4e5d407fa3 + nokogiri (1.19.1-arm-linux-musl) sha256=3a18e559ee499b064aac6562d98daab3d39ba6cbb4074a1542781b2f556db47d + nokogiri (1.19.1-arm64-darwin) sha256=dfe2d337e6700eac47290407c289d56bcf85805d128c1b5a6434ddb79731cb9e + nokogiri (1.19.1-x86_64-darwin) sha256=7093896778cc03efb74b85f915a775862730e887f2e58d6921e3fa3d981e68bf + nokogiri (1.19.1-x86_64-linux-gnu) sha256=1a4902842a186b4f901078e692d12257678e6133858d0566152fe29cdb98456a + nokogiri (1.19.1-x86_64-linux-musl) sha256=4267f38ad4fc7e52a2e7ee28ed494e8f9d8eb4f4b3320901d55981c7b995fc23 parallel (1.27.0) sha256=4ac151e1806b755fb4e2dc2332cbf0e54f2e24ba821ff2d3dcf86bf6dc4ae130 - parser (3.3.9.0) sha256=94d6929354b1a6e3e1f89d79d4d302cc8f5aa814431a6c9c7e0623335d7687f2 - prism (1.5.1) sha256=b40c1b76ccb9fcccc3d1553967cda6e79fa7274d8bfea0d98b15d27a6d187134 - public_suffix (6.0.2) sha256=bfa7cd5108066f8c9602e0d6d4114999a5df5839a63149d3e8b0f9c1d3558394 - puma (7.0.4) sha256=b4c6ae11d1458052eeaa415176c3bf0000f4232287f413525ab2504446154b7a - puppeteer-ruby (0.45.6) sha256=cb86f7b4f6f8658a709ae1a305e820bdb009548e6beff6675489926f9ceb5995 + parser (3.3.10.2) sha256=6f60c84aa4bdcedb6d1a2434b738fe8a8136807b6adc8f7f53b97da9bc4e9357 + prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 + protocol-hpack (1.5.1) sha256=6feca238b8078da1cd295677d6f306c6001af92d75fe0643d33e6956cbc3ad91 + protocol-http (0.60.0) sha256=ca1354947676d663b6f23c49654aee464288774e7867c4a6e406fecce9691cec + protocol-http1 (0.37.0) sha256=5bdd739e28792b341134596f6f5ab21a9d4b395f67bae69e153743eb0e69d123 + protocol-http2 (0.24.0) sha256=65327a019b7e36d2774e94050bf57a43bb60212775d2fcf02ae1d2ed4f01ef28 + protocol-rack (0.21.1) sha256=366ff16efbf4c2f8d2e3fad4e992effa2357610f70effbccfa2767d26fedc577 + protocol-url (0.4.0) sha256=64d4c03b6b51ad815ac6fdaf77a1d91e5baf9220d26becb846c5459dacdea9e1 + protocol-websocket (0.20.2) sha256=c41d93c35fba5dae85375c597f76975f3dbd75d8c5b2f21b33dab4dc22a5a511 + public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623 + puma (7.2.0) sha256=bf8ef4ab514a4e6d4554cb4326b2004eba5036ae05cf765cfe51aba9706a72a8 + puppeteer-ruby (0.51.0) sha256=8a7637963f8cd5b88416dd8c669a3ec2fe40a42cda2449539d75525a4da2f233 racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f - rack (3.2.1) sha256=30af3f7e5ec21b0d14d822cf24446048dba5f651b617c7e97405b604f20a9e33 + rack (3.2.5) sha256=4cbd0974c0b79f7a139b4812004a62e4c60b145cba76422e288ee670601ed6d3 rack-cache (1.17.0) sha256=49592f3ef2173b0f5524df98bb801fb411e839869e7ce84ac428dc492bf0eb90 rack-session (2.1.1) sha256=0b6dc07dea7e4b583f58a48e8b806d4c9f1c6c9214ebc202ec94562cbea2e4e9 rack-test (2.2.0) sha256=005a36692c306ac0b4a9350355ee080fd09ddef1148a5f8b2ac636c720f5c463 @@ -395,47 +487,49 @@ CHECKSUMS rails-dom-testing (2.3.0) sha256=8acc7953a7b911ca44588bf08737bc16719f431a1cc3091a292bca7317925c1d rails-html-sanitizer (1.7.0) sha256=28b145cceaf9cc214a9874feaa183c3acba036c9592b19886e0e45efc62b1e89 rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a - rake (13.3.0) sha256=96f5092d786ff412c62fde76f793cc0541bd84d2eb579caa529aa8a059934493 - rbs (3.9.5) sha256=eabaaf60aee84e38cbf94839c6e1b9cd145c7295fc3cc0e88c92e4069b1119b0 + rake (13.3.1) sha256=8c9e89d09f66a26a01264e7e3480ec0607f0c497a861ef16063604b1b08eb19c + rbs (3.10.3) sha256=70627f3919016134d554e6c99195552ae3ef6020fe034c8e983facc9c192daa6 regexp_parser (2.11.3) sha256=ca13f381a173b7a93450e53459075c9b76a10433caadcb2f1180f2c741fc55a4 - reverse_markdown (3.0.0) sha256=ab228386765a0259835873cd07054b62939c40f620c77c247eafaaa3b23faca4 + reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835 + reverse_markdown (3.0.2) sha256=818ebb92ce39dbb1a291690dd1ec9a6d62530d4725296b17e9c8f668f9a5b8af rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 - roda (3.96.0) sha256=e89397269a2713c3ce8e95156c7579309e514e808dc124b50657c7bdadff90df - rspec (3.13.1) sha256=b9f9a58fa915b8d94a1d6b3195fe6dd28c4c34836a6097015142c4a9ace72140 - rspec-core (3.13.5) sha256=ab3f682897c6131c67f9a17cfee5022a597f283aebe654d329a565f9937a4fa3 + roda (3.102.0) sha256=b2156fff6d2b1b52bfac39e4ccde0d820a26594f069c3d9e99cc0853f7ee7dcc + rspec (3.13.2) sha256=206284a08ad798e61f86d7ca3e376718d52c0bc944626b2349266f239f820587 + rspec-core (3.13.6) sha256=a8823c6411667b60a8bca135364351dda34cd55e44ff94c4be4633b37d828b2d rspec-expectations (3.13.5) sha256=33a4d3a1d95060aea4c94e9f237030a8f9eae5615e9bd85718fe3a09e4b58836 - rspec-mocks (3.13.5) sha256=e4338a6f285ada9fe56f5893f5457783af8194f5d08884d17a87321d5195ea81 - rspec-openapi (0.24.0) sha256=e8ce06261b449b7b30bce5875e1475c0fcff3ad3610f321cdf5ad4290edf8584 - rspec-support (3.13.6) sha256=2e8de3702427eab064c9352fe74488cc12a1bfae887ad8b91cba480ec9f8afb2 - rss (0.3.1) sha256=b46234c04551b925180f8bedfc6f6045bf2d9998417feda72f300e7980226737 - rubocop (1.81.1) sha256=352a9a6f314a4312f6c305f1f72bc466254d221c95445cd49e1b65d1f9411635 - rubocop-ast (1.47.1) sha256=592682017855408b046a8190689490763aecea175238232b1b526826349d01ae - rubocop-performance (1.26.0) sha256=7bb0d9d9fb2ea122bf6f9a596dd7cf9dc93ab4950923d26c4ae4f328cef71ca9 + rspec-mocks (3.13.8) sha256=086ad3d3d17533f4237643de0b5c42f04b66348c28bf6b9c2d3f4a3b01af1d47 + rspec-openapi (0.25.0) sha256=76e055d3ee421a2a0c5d45986ae958f045f149def995b55ee9d2c68318f38305 + rspec-support (3.13.7) sha256=0640e5570872aafefd79867901deeeeb40b0c9875a36b983d85f54fb7381c47c + rss (0.3.2) sha256=3bd0446d32d832cda00ba07f4b179401f903b52ea1fdaac0f1f08de61a501efa + rubocop (1.85.1) sha256=3dbcf9e961baa4c376eeeb2a03913dca5e3987033b04d38fa538aa1e7406cc77 + rubocop-ast (1.49.1) sha256=4412f3ee70f6fe4546cc489548e0f6fcf76cafcfa80fa03af67098ffed755035 + rubocop-performance (1.26.1) sha256=cd19b936ff196df85829d264b522fd4f98b6c89ad271fa52744a8c11b8f71834 rubocop-rake (0.7.1) sha256=3797f2b6810c3e9df7376c26d5f44f3475eda59eb1adc38e6f62ecf027cbae4d - rubocop-rspec (3.7.0) sha256=b7b214da112034db9c6d00f2d811a354847e870f7b6ed2482b29649c3d42058f + rubocop-rspec (3.9.0) sha256=8fa70a3619408237d789aeecfb9beef40576acc855173e60939d63332fdb55e2 rubocop-thread_safety (0.7.3) sha256=067cdd52fbf5deffc18995437e45b5194236eaff4f71de3375a1f6052e48f431 - ruby-lsp (0.26.1) sha256=d140c75df25cd1a6475c17a84ce650aa81608e77ca0642d4ef4363f2c6791814 + ruby-lsp (0.26.8) sha256=fa607c342736c6ea791c945ae05025bc306ef9dd72c640b0b660478c5832f968 ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 sanitize (7.0.0) sha256=269d1b9d7326e69307723af5643ec032ff86ad616e72a3b36d301ac75a273984 securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 - sentry-ruby (5.28.0) sha256=745c1db3b85c765993dfeb941830aeafff82d85330cd953410c7f37197b0bd99 + sentry-ruby (6.4.1) sha256=dac04976f791ad6ecd4fd30440c29d9b73aee08f790eeca73b439b5d67370f38 simplecov (0.22.0) sha256=fe2622c7834ff23b98066bb0a854284b2729a569ac659f82621fc22ef36213a5 simplecov-html (0.13.2) sha256=bd0b8e54e7c2d7685927e8d6286466359b6f16b18cb0df47b508e8d73c777246 simplecov_json_formatter (0.1.4) sha256=529418fbe8de1713ac2b2d612aa3daa56d316975d307244399fa4838c601b428 ssrf_filter (1.3.0) sha256=66882d7de7d09c019098d6d7372412950ae184ebbc7c51478002058307aba6f2 - stackprof (0.2.27) sha256=aff6d28656c852e74cf632cc2046f849033dc1dedffe7cb8c030d61b5745e80c - thor (1.4.0) sha256=8763e822ccb0f1d7bee88cde131b19a65606657b847cc7b7b4b82e772bcd8a3d + stackprof (0.2.28) sha256=4ec2ace02f386012b40ca20ef80c030ad711831f59511da12e83b34efb0f9a04 + thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73 + traces (0.18.2) sha256=80f1649cb4daace1d7174b81f3b3b7427af0b93047759ba349960cb8f315e214 + tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42 - unicode-emoji (4.1.0) sha256=4997d2d5df1ed4252f4830a9b6e86f932e2013fbff2182a9ce9ccabda4f325a5 - uri (1.0.3) sha256=e9f2244608eea2f7bc357d954c65c910ce0399ca5e18a7a29207ac22d8767011 + unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f + uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6 useragent (0.16.11) sha256=700e6413ad4bb954bb63547fa098dddf7b0ebe75b40cc6f93b8d54255b173844 - vcr (6.3.1) sha256=37b56e157e720446a3f4d2d39919cabef8cb7b6c45936acffd2ef8229fec03ed - webmock (3.25.1) sha256=ab9d5d9353bcbe6322c83e1c60a7103988efc7b67cd72ffb9012629c3d396323 - websocket-driver (0.8.0) sha256=ed0dba4b943c22f17f9a734817e808bc84cdce6a7e22045f5315aa57676d4962 - websocket-extensions (0.1.5) sha256=1c6ba63092cda343eb53fc657110c71c754c56484aad42578495227d717a8241 - yard (0.9.37) sha256=a6e910399e78e613f80ba9add9ba7c394b1a935f083cccbef82903a3d2a26992 - zeitwerk (2.7.3) sha256=b2e86b4a9b57d26ba68a15230dcc7fe6f040f06831ce64417b0621ad96ba3e85 + vcr (6.4.0) sha256=077ac92cc16efc5904eb90492a18153b5e6ca5398046d8a249a7c96a9ea24ae6 + webmock (3.26.1) sha256=4f696fb57c90a827c20aadb2d4f9058bbff10f7f043bd0d4c3f58791143b1cd7 + yard (0.9.38) sha256=721fb82afb10532aa49860655f6cc2eaa7130889df291b052e1e6b268283010f + zeitwerk (2.7.5) sha256=d8da92128c09ea6ec62c949011b00ed4a20242b255293dd66bf41545398f73dd + zlib (3.2.3) sha256=5bd316698b32f31a64ab910a8b6c282442ca1626a81bbd6a1674e8522e319c20 BUNDLED WITH 2.6.6 From 097ae67a11b09327e721b766b681e02e7c806e0b Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sat, 14 Mar 2026 16:41:07 +0100 Subject: [PATCH 10/11] fix: harden feed loading and negotiated openapi docs --- app/feeds/cache.rb | 25 +++++++---- app/feeds/service.rb | 1 + app/http/feed_route_handler.rb | 1 + docs/api/v1/openapi.yaml | 33 +++++++++++++- spec/html2rss/web/api/v1_spec.rb | 33 +++++++++++++- spec/support/openapi.rb | 74 ++++++++++++++++++++++++++++---- 6 files changed, 148 insertions(+), 19 deletions(-) diff --git a/app/feeds/cache.rb b/app/feeds/cache.rb index 310200c3..4bb3817e 100644 --- a/app/feeds/cache.rb +++ b/app/feeds/cache.rb @@ -21,21 +21,25 @@ class << self # @yieldreturn [Html2rss::Web::Feeds::Result] # @return [Html2rss::Web::Feeds::Result] def fetch(key, ttl_seconds:, cacheable: true) - entry = read_entry(key) - return entry.result if fresh?(entry) + lock.synchronize do + entry = read_entry(key) + return entry.result if fresh?(entry) - result = yield - return result unless cacheable_result?(cacheable, result) + result = yield + return result unless cacheable_result?(cacheable, result) - write_entry(key, ttl_seconds, result) - result + write_entry(key, ttl_seconds, result) + result + end end # @param reason [String] # @return [nil] def clear!(reason: 'manual') - @entries = {} # rubocop:disable ThreadSafety/ClassInstanceVariable - SecurityLogger.log_cache_lifecycle('feeds_cache', 'clear', reason: reason) + lock.synchronize do + @entries = {} + SecurityLogger.log_cache_lifecycle('feeds_cache', 'clear', reason: reason) + end nil end @@ -76,6 +80,11 @@ def entries @entries ||= {} # rubocop:disable ThreadSafety/ClassInstanceVariable end + # @return [Mutex] + def lock + @lock ||= Mutex.new # rubocop:disable ThreadSafety/ClassInstanceVariable + end + # @param ttl_seconds [Integer] # @return [Integer] def normalize_ttl(ttl_seconds) diff --git a/app/feeds/service.rb b/app/feeds/service.rb index 84dd920f..0cbf15b5 100644 --- a/app/feeds/service.rb +++ b/app/feeds/service.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +require_relative '../exceptions' require_relative 'cache' require_relative 'payload' require_relative 'result' diff --git a/app/http/feed_route_handler.rb b/app/http/feed_route_handler.rb index 6cab02ee..36f2d2e3 100644 --- a/app/http/feed_route_handler.rb +++ b/app/http/feed_route_handler.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +require_relative '../exceptions' require_relative '../feed_response_format' require_relative '../feeds/json_renderer' require_relative '../feeds/request_parser' diff --git a/docs/api/v1/openapi.yaml b/docs/api/v1/openapi.yaml index 094469ee..2394a81d 100644 --- a/docs/api/v1/openapi.yaml +++ b/docs/api/v1/openapi.yaml @@ -192,6 +192,9 @@ paths: responses: '200': content: + application/feed+json: + schema: + type: string application/xml: schema: type: string @@ -199,18 +202,46 @@ paths: '401': content: application/feed+json: + example: + title: Error + version: https://jsonfeed.org/version/1.1 + schema: + type: string + application/xml: + example: |- + + ErrorInternal Server Error schema: type: string description: returns JSON Feed-shaped errors when requested by json extension '403': content: + application/feed+json: + example: + title: Error + version: https://jsonfeed.org/version/1.1 + schema: + type: string application/xml: + example: |- + + ErrorInternal Server Error schema: type: string - description: returns forbidden when auto source is disabled + description: returns JSON Feed-shaped forbidden errors when requested through + Accept '500': content: application/feed+json: + example: + title: Error + version: https://jsonfeed.org/version/1.1 + schema: + type: string + application/xml: + example: |- + + ErrorInternal Server Error schema: type: string description: returns non-cacheable json feed errors when service generation diff --git a/spec/html2rss/web/api/v1_spec.rb b/spec/html2rss/web/api/v1_spec.rb index c411b6a4..6ec59103 100644 --- a/spec/html2rss/web/api/v1_spec.rb +++ b/spec/html2rss/web/api/v1_spec.rb @@ -224,7 +224,8 @@ def json_feed_headers_tuple summary: 'Render feed by token', operation_id: 'renderFeedByToken', tags: ['Feeds'], - security: [] + security: [], + example_mode: :multiple } do before do stub_const('Html2rss::FeedChannel', Class.new { attr_reader :ttl }) @@ -312,8 +313,36 @@ def json_feed_headers_tuple expect(last_response.body).to include(Html2rss::Web::Api::V1::Contract::MESSAGES[:auto_source_disabled]) end + it 'returns JSON Feed-shaped forbidden errors when requested through Accept', :aggregate_failures do + unique_url = "#{feed_url}/disabled-json" + token = Html2rss::Web::Auth.generate_feed_token('admin', unique_url, strategy: 'ssrf_filter') + + ClimateControl.modify(AUTO_SOURCE_ENABLED: 'false') do + get "/api/v1/feeds/#{token}", {}, { 'HTTP_ACCEPT' => 'application/feed+json' } + end + + expect([last_response.status, last_response.headers['Content-Type'], json_feed_error]).to eq( + [403, 'application/feed+json', { 'version' => 'https://jsonfeed.org/version/1.1', 'title' => 'Error' }] + ) + end + + it 'returns non-cacheable xml feed errors when service generation fails', :aggregate_failures do + unique_url = "#{feed_url}/service-error-xml" + token = Html2rss::Web::Auth.generate_feed_token('admin', unique_url, strategy: 'ssrf_filter') + + allow(Html2rss::Web::Feeds::Service).to receive(:call).and_return(service_error_result) + + get "/api/v1/feeds/#{token}.xml" + + expect(last_response.status).to eq(500) + expect(last_response.content_type).to include('application/xml') + expect(last_response.headers['Cache-Control']).to include('no-store') + expect(last_response.body).to include('Internal Server Error') + end + it 'returns non-cacheable json feed errors when service generation fails', :aggregate_failures do - token = Html2rss::Web::Auth.generate_feed_token('admin', feed_url, strategy: 'ssrf_filter') + unique_url = "#{feed_url}/service-error-json" + token = Html2rss::Web::Auth.generate_feed_token('admin', unique_url, strategy: 'ssrf_filter') status, content_type, cache_control, title = json_feed_service_error_tuple(token) diff --git a/spec/support/openapi.rb b/spec/support/openapi.rb index 0f1d980f..944406f0 100644 --- a/spec/support/openapi.rb +++ b/spec/support/openapi.rb @@ -47,6 +47,8 @@ # Keep path keys relative to /api/v1 because servers include the versioned base path. RSpec::OpenAPI.post_process_hook = lambda do |_path, _records, spec| + token_feed_error_statuses = %w[401 403 500].freeze + stringify = lambda do |value| case value when Hash @@ -69,6 +71,51 @@ end end + merge_responses = lambda do |existing_responses, new_responses| + statuses = existing_responses.keys | new_responses.keys + + statuses.each_with_object({}) do |status, merged_responses| + current = existing_responses[status] || {} + incoming = new_responses[status] || {} + merged_response = current.merge(incoming) + + current_content = current['content'] || {} + incoming_content = incoming['content'] || {} + if current_content.any? || incoming_content.any? + content_types = current_content.keys | incoming_content.keys + merged_response['content'] = content_types.to_h do |content_type| + current_entry = current_content[content_type] || {} + incoming_entry = incoming_content[content_type] || {} + [content_type, current_entry.merge(incoming_entry)] + end + end + + current_headers = current['headers'] || {} + incoming_headers = incoming['headers'] || {} + if current_headers.any? || incoming_headers.any? + merged_response['headers'] = current_headers.merge(incoming_headers) + end + + merged_response['description'] ||= current['description'] || incoming['description'] + merged_responses[status] = merged_response + end + end + + token_feed_error_examples = { + 'application/xml' => { + 'example' => <<~XML.strip + + ErrorInternal Server Error + XML + }, + 'application/feed+json' => { + 'example' => { + 'version' => 'https://jsonfeed.org/version/1.1', + 'title' => 'Error' + } + } + } + path_map = spec['paths'] || spec[:paths] next unless path_map.is_a?(Hash) @@ -90,7 +137,7 @@ if existing merged = existing.merge(operation_doc) - merged['responses'] = (existing['responses'] || {}).merge(operation_doc['responses'] || {}) + merged['responses'] = merge_responses.call(existing['responses'] || {}, operation_doc['responses'] || {}) merged['parameters'] = [*(existing['parameters'] || []), *(operation_doc['parameters'] || [])] merged['parameters'].uniq! { |parameter| [parameter['name'], parameter['in']] } normalized_paths[normalized][verb] = deep_sort.call(merged) @@ -106,14 +153,25 @@ has_token_param = normalized_paths[normalized][verb]['parameters'].any? do |parameter| parameter['name'] == 'token' && parameter['in'] == 'path' end - next if has_token_param + unless has_token_param + normalized_paths[normalized][verb]['parameters'] << { + 'name' => 'token', + 'in' => 'path', + 'required' => true, + 'schema' => { 'type' => 'string' } + } + end - normalized_paths[normalized][verb]['parameters'] << { - 'name' => 'token', - 'in' => 'path', - 'required' => true, - 'schema' => { 'type' => 'string' } - } + token_feed_error_statuses.each do |status| + response = normalized_paths[normalized][verb].dig('responses', status) + next unless response + + response['content'] ||= {} + token_feed_error_examples.each do |content_type, example| + response['content'][content_type] ||= { 'schema' => { 'type' => 'string' } } + response['content'][content_type].merge!(example) + end + end end end From 1f6c30e0cd005014f65f48b76910fe6a4e9a5f13 Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sat, 14 Mar 2026 16:41:37 +0100 Subject: [PATCH 11/11] chore: refresh generated api types --- frontend/src/api/generated/types.gen.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/api/generated/types.gen.ts b/frontend/src/api/generated/types.gen.ts index 8f3919c4..06b0fd05 100644 --- a/frontend/src/api/generated/types.gen.ts +++ b/frontend/src/api/generated/types.gen.ts @@ -107,9 +107,9 @@ export type RenderFeedByTokenErrors = { */ 401: string; /** - * returns forbidden when auto source is disabled + * returns JSON Feed-shaped forbidden errors when requested through Accept */ - 403: Blob | File; + 403: string; /** * returns non-cacheable json feed errors when service generation fails */ @@ -122,7 +122,7 @@ export type RenderFeedByTokenResponses = { /** * renders feed for a valid token */ - 200: Blob | File; + 200: string; }; export type RenderFeedByTokenResponse = RenderFeedByTokenResponses[keyof RenderFeedByTokenResponses];