diff --git a/app/web/errors/error_responder.rb b/app/web/errors/error_responder.rb index 2d2da987..eb453c6b 100644 --- a/app/web/errors/error_responder.rb +++ b/app/web/errors/error_responder.rb @@ -53,9 +53,7 @@ def render_feed_error(request, response, error, decision) f = FeedResponseFormat.for_request(request) response['Content-Type'] = FeedResponseFormat.content_type(f) msg = client_message_for(error, decision) - return JsonFeedBuilder.build_error_feed(message: msg) if f == FeedResponseFormat::JSON_FEED - - XmlBuilder.build_error_feed(message: msg) + Feeds::Renderer.call_error(message: msg, format: f) end def render_api_error(_request, response, error, decision) @@ -65,7 +63,7 @@ def render_api_error(_request, response, error, decision) def render_xml_error(response, error, decision) response['Content-Type'] = 'application/xml' - XmlBuilder.build_error_feed(message: client_message_for(error, decision)) + Feeds::Renderer.call_error(message: client_message_for(error, decision), format: :rss) end def resolve_error_code(error, decision) diff --git a/app/web/feeds/json_renderer.rb b/app/web/feeds/json_renderer.rb deleted file mode 100644 index e1fe0c82..00000000 --- a/app/web/feeds/json_renderer.rb +++ /dev/null @@ -1,84 +0,0 @@ -# frozen_string_literal: true - -require 'json' -require 'time' - -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::Contracts::RenderResult] - # @return [String] - def call(result) - case result.status - when :ok - JSON.generate(payload_for(result.payload.feed)) - when :empty - empty_feed(result) - else - error_feed(result) - end - end - - private - - # @param result [Html2rss::Web::Feeds::Contracts::RenderResult] - # @return [String] - def empty_feed(result) - JsonFeedBuilder.build_empty_feed_warning( - url: result.payload.url, - strategy: result.payload.strategy, - site_title: result.payload.site_title - ) - end - - # @param result [Html2rss::Web::Feeds::Contracts::RenderResult] - # @return [String] - def error_feed(result) - JsonFeedBuilder.build_error_feed(message: result.message || Html2rss::Web::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/web/feeds/renderer.rb b/app/web/feeds/renderer.rb new file mode 100644 index 00000000..045afc82 --- /dev/null +++ b/app/web/feeds/renderer.rb @@ -0,0 +1,231 @@ +# frozen_string_literal: true + +require 'rss' +require 'json' +require 'time' + +module Html2rss + module Web + module Feeds + ## + # rubocop:disable Metrics/ModuleLength, Metrics/ClassLength, Metrics/MethodLength, Metrics/AbcSize + module Renderer + JSON_FEED_VERSION = 'https://jsonfeed.org/version/1.1' + + EMPTY_FEED_DESCRIPTION_TEMPLATE = <<~DESC + We could not extract entries from %s right now. + The source may block automated requests, require dynamic rendering, or be temporarily unavailable. + DESC + + EMPTY_FEED_ITEM_TEMPLATE = <<~DESC + No entries were extracted from %s. + + What you can do: + - Try again in a few moments + - Open the original page to confirm content is available + - Reach out to the site owner if access is restricted + DESC + + class << self + # Renders a RenderResult into the requested format. + # + # @param result [Html2rss::Web::Feeds::Contracts::RenderResult] + # @param format [Symbol] :rss or :json_feed + # @return [String] serialized feed representation + def call(result, format:) + case result.status + when :ok + render_success(result, format) + when :empty + render_empty(result, format) + else + call_error(message: result.message || HttpError::DEFAULT_MESSAGE, format: format) + end + end + + # Renders a single-item error feed in the requested format. + # + # @param message [String] the error message to display in the feed. + # @param format [Symbol] :rss or :json_feed + # @return [String] serialized error feed representation + def call_error(message:, format:) + title = 'Error' + desc = "Failed to generate feed: #{message}" + timestamp = Time.now.utc + + if format == FeedResponseFormat::JSON_FEED + JSON.generate({ + version: JSON_FEED_VERSION, + title: title, + description: desc, + items: [{ + id: "#{title}-#{timestamp.iso8601}", + title: title, + content_text: message, + date_published: timestamp.iso8601 + }] + }) + else + build_rss( + title: title, + description: desc, + items: [{ + title: title, + description: message, + pubDate: timestamp + }], + timestamp: timestamp + ) + end + end + + private + + # @param result [Html2rss::Web::Feeds::Contracts::RenderResult] + # @param format [Symbol] + # @return [String] + def render_success(result, format) + if format == FeedResponseFormat::JSON_FEED + serialize_json_feed(result.payload.feed) + else + result.payload.feed.to_s + end + end + + # @param result [Html2rss::Web::Feeds::Contracts::RenderResult] + # @param format [Symbol] + # @return [String] + def render_empty(result, format) + url = result.payload.url + strategy = result.payload.strategy + site_title = result.payload.site_title + + title = empty_feed_title(site_title) + desc = empty_feed_description(url, strategy) + timestamp = Time.now.utc + + if format == FeedResponseFormat::JSON_FEED + JSON.generate({ + version: JSON_FEED_VERSION, + title: title, + home_page_url: url, + description: desc, + items: [{ + id: url, + url: url, + title: 'Preview unavailable for this source', + content_text: empty_feed_item(url), + date_published: timestamp.iso8601 + }] + }.compact) + else + build_rss( + title: title, + description: desc, + link: url, + items: [{ + title: 'Preview unavailable for this source', + description: empty_feed_item(url), + link: url, + pubDate: timestamp + }], + timestamp: timestamp + ) + end + end + + # @param feed [RSS::Rss] + # @return [String] + def serialize_json_feed(feed) + payload = { + version: JSON_FEED_VERSION, + title: feed.channel.title, + home_page_url: feed.channel.link, + description: feed.channel.description, + items: feed.items.map { |item| serialize_json_item(item) } + }.compact + + JSON.generate(payload) + end + + # @param item [Object] + # @return [Hash{Symbol=>Object}] + def serialize_json_item(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: parse_date(item) + }.compact + end + + # @param item [Object] + # @return [String, nil] + def parse_date(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 + + # @param title [String] + # @param description [String] + # @param link [String, nil] + # @param items [Array] + # @param timestamp [Time] + # @return [String] + def build_rss(title:, description:, link: nil, items: [], timestamp: nil) + RSS::Maker.make('2.0') do |maker| + # Apply stylesheets + stylesheets = Html2rss.configuration.stylesheets.map do |s| + Html2rss::RssBuilder::Stylesheet.new(**s) + end + Html2rss::RssBuilder::Stylesheet.add(maker, stylesheets) + + # Channel details + now = timestamp || Time.now + maker.channel.title = title.to_s + maker.channel.description = description.to_s + maker.channel.link = link.to_s + maker.channel.lastBuildDate = now + maker.channel.pubDate = now + + # Items + items.each do |item| + maker.items.new_item do |i| + i.title = item[:title].to_s + i.description = item[:description].to_s + i.link = item[:link].to_s + i.pubDate = item[:pubDate] || now + end + end + end.to_s + end + + # @param site_title [String, nil] + # @return [String] + def empty_feed_title(site_title) + site_title ? "#{site_title} - Content Extraction Issue" : 'Content Extraction Issue' + end + + # @param url [String] + # @param strategy [String] + # @return [String] + def empty_feed_description(url, strategy) + format(EMPTY_FEED_DESCRIPTION_TEMPLATE, url: url, strategy: strategy) + end + + # @param url [String] + # @return [String] + def empty_feed_item(url) + format(EMPTY_FEED_ITEM_TEMPLATE, url: url) + end + end + end + # rubocop:enable Metrics/ModuleLength, Metrics/ClassLength, Metrics/MethodLength, Metrics/AbcSize + end + end +end diff --git a/app/web/feeds/responder.rb b/app/web/feeds/responder.rb index 88974e94..d10197fd 100644 --- a/app/web/feeds/responder.rb +++ b/app/web/feeds/responder.rb @@ -81,9 +81,7 @@ def apply_cache_headers(response, result) # @param representation [Symbol] # @return [String] def render_result(result, representation) - return JsonRenderer.call(result) if representation == FeedResponseFormat::JSON_FEED - - RssRenderer.call(result) + Renderer.call(result, format: representation) end # @param target_kind [Symbol] diff --git a/app/web/feeds/rss_renderer.rb b/app/web/feeds/rss_renderer.rb deleted file mode 100644 index 6313269d..00000000 --- a/app/web/feeds/rss_renderer.rb +++ /dev/null @@ -1,44 +0,0 @@ -# frozen_string_literal: true - -module Html2rss - module Web - module Feeds - ## - # Renders RSS bodies from shared feed results. - module RssRenderer - class << self - # @param result [Html2rss::Web::Feeds::Contracts::RenderResult] - # @return [String] - def call(result) - case result.status - when :ok - result.payload.feed.to_s - when :empty - empty_feed(result) - else - error_feed(result) - end - end - - private - - # @param result [Html2rss::Web::Feeds::Contracts::RenderResult] - # @return [String] - def empty_feed(result) - XmlBuilder.build_empty_feed_warning( - url: result.payload.url, - strategy: result.payload.strategy, - site_title: result.payload.site_title - ) - end - - # @param result [Html2rss::Web::Feeds::Contracts::RenderResult] - # @return [String] - def error_feed(result) - XmlBuilder.build_error_feed(message: result.message || Html2rss::Web::HttpError::DEFAULT_MESSAGE) - end - end - end - end - end -end diff --git a/app/web/rendering/feed_accept_header.rb b/app/web/rendering/feed_accept_header.rb deleted file mode 100644 index 595d6c09..00000000 --- a/app/web/rendering/feed_accept_header.rb +++ /dev/null @@ -1,98 +0,0 @@ -# frozen_string_literal: true - -module Html2rss - module Web - ## - # Parses Accept headers for feed representation negotiation. - module FeedAcceptHeader - MediaRange = Data.define(:type, :subtype, :quality, :position) do - # @return [Integer] - def specificity - return 0 if type == '*' && subtype == '*' - return 1 if subtype == '*' - - 2 - end - - # @param candidate [String] - # @return [Boolean] - def matches?(candidate) - candidate_type, candidate_subtype = candidate.downcase.split('/', 2) - return true if type == '*' && subtype == '*' - return candidate_type == type if subtype == '*' - - candidate_type == type && candidate_subtype == subtype - end - end - - class << self - # @param accept_header [String, nil] - # @param json_media_types [Array] - # @param rss_media_types [Array] - # @return [Symbol, nil] - def preferred_format(accept_header, json_media_types:, rss_media_types:) - media_ranges = parse(accept_header) - return nil if media_ranges.empty? - - json_score = best_score(media_ranges, json_media_types) - rss_score = best_score(media_ranges, rss_media_types) - - return nil unless json_score - return FeedResponseFormat::JSON_FEED if rss_score.nil? - - (json_score <=> rss_score)&.positive? ? FeedResponseFormat::JSON_FEED : nil - end - - private - - # @param accept_header [String, nil] - # @return [Array] - def parse(accept_header) - accept_header.to_s.split(',').filter_map.with_index do |raw_range, position| - build_media_range(raw_range, position) - end - end - - # @param raw_range [String] - # @param position [Integer] - # @return [MediaRange, nil] - def build_media_range(raw_range, position) - media_type, *parameter_parts = raw_range.strip.downcase.split(';') - type, subtype = media_type.to_s.split('/', 2) - return if type.to_s.empty? || subtype.to_s.empty? - - MediaRange.new( - type:, - subtype:, - quality: extract_quality(parameter_parts), - position: - ) - end - - # @param parameter_parts [Array] - # @return [Float] - def extract_quality(parameter_parts) - raw_value = parameter_parts - .map(&:strip) - .find { |part| part.start_with?('q=') } - &.split('=', 2) - &.last - quality = raw_value ? Float(raw_value) : 1.0 - quality.clamp(0.0, 1.0) - rescue ArgumentError - 1.0 - end - - # @param media_ranges [Array] - # @param candidates [Array] - # @return [Array(Float, Integer, Integer), nil] - def best_score(media_ranges, candidates) - media_ranges - .filter { |range| range.quality.positive? && candidates.any? { |candidate| range.matches?(candidate) } } - .map { |range| [range.quality, range.specificity, -range.position] } - .max - end - end - end - end -end diff --git a/app/web/rendering/feed_notice_text.rb b/app/web/rendering/feed_notice_text.rb deleted file mode 100644 index ed34d3ad..00000000 --- a/app/web/rendering/feed_notice_text.rb +++ /dev/null @@ -1,44 +0,0 @@ -# frozen_string_literal: true - -module Html2rss - module Web - ## - # Shared copy helpers for rendered feed warnings and fallback documents. - module FeedNoticeText - EMPTY_FEED_DESCRIPTION_TEMPLATE = <<~DESC - We could not extract entries from %s right now. - The source may block automated requests, require dynamic rendering, or be temporarily unavailable. - DESC - - EMPTY_FEED_ITEM_TEMPLATE = <<~DESC - No entries were extracted from %s. - - What you can do: - - Try again in a few moments - - Open the original page to confirm content is available - - Reach out to the site owner if access is restricted - DESC - - class << self - # @param site_title [String, nil] - # @return [String] - def empty_feed_title(site_title) - site_title ? "#{site_title} - Content Extraction Issue" : 'Content Extraction Issue' - end - - # @param url [String] - # @param strategy [String] - # @return [String] - def empty_feed_description(url:, strategy:) - format(EMPTY_FEED_DESCRIPTION_TEMPLATE, url: url, strategy: strategy) - end - - # @param url [String] - # @return [String] - def empty_feed_item(url:) - format(EMPTY_FEED_ITEM_TEMPLATE, url: url) - end - end - end - end -end diff --git a/app/web/rendering/feed_response_format.rb b/app/web/rendering/feed_response_format.rb index f74898a6..b8391e82 100644 --- a/app/web/rendering/feed_response_format.rb +++ b/app/web/rendering/feed_response_format.rb @@ -4,6 +4,7 @@ module Html2rss module Web ## # Resolves feed response formats from request paths and Accept headers. + # rubocop:disable Metrics/ModuleLength module FeedResponseFormat JSON_FEED = :json_feed RSS = :rss @@ -28,6 +29,26 @@ module FeedResponseFormat 'text/xml' ].freeze + MediaRange = Data.define(:type, :subtype, :quality, :position) do + # @return [Integer] + def specificity + return 0 if type == '*' && subtype == '*' + return 1 if subtype == '*' + + 2 + end + + # @param candidate [String] + # @return [Boolean] + def matches?(candidate) + candidate_type, candidate_subtype = candidate.downcase.split('/', 2) + return true if type == '*' && subtype == '*' + return candidate_type == type if subtype == '*' + + candidate_type == type && candidate_subtype == subtype + end + end + class << self # @param request [Rack::Request] # @return [Symbol] negotiated feed format. @@ -63,6 +84,23 @@ def content_type(format) format == JSON_FEED ? JSON_CONTENT_TYPE : RSS_CONTENT_TYPE end + # Parses Accept header and returns the preferred format based on priority. + # + # @param accept_header [String, nil] + # @return [Symbol, nil] preferred format (:json_feed, or nil meaning fallback to rss) + def from_accept(accept_header) + media_ranges = parse_accept(accept_header) + return nil if media_ranges.empty? + + json_score = best_score(media_ranges, JSON_MEDIA_TYPES) + rss_score = best_score(media_ranges, RSS_MEDIA_TYPES) + + return nil unless json_score + return JSON_FEED if rss_score.nil? + + (json_score <=> rss_score)&.positive? ? JSON_FEED : nil + end + private # @param request [Rack::Request] @@ -90,15 +128,54 @@ def request_path_fallback?(request, path) end # @param accept_header [String, nil] - # @return [Symbol, nil] - def from_accept(accept_header) - FeedAcceptHeader.preferred_format( - accept_header, - json_media_types: JSON_MEDIA_TYPES, - rss_media_types: RSS_MEDIA_TYPES + # @return [Array] + def parse_accept(accept_header) + accept_header.to_s.split(',').filter_map.with_index do |raw_range, position| + build_media_range(raw_range, position) + end + end + + # @param raw_range [String] + # @param position [Integer] + # @return [MediaRange, nil] + def build_media_range(raw_range, position) + media_type, *parameter_parts = raw_range.strip.downcase.split(';') + type, subtype = media_type.to_s.split('/', 2) + return if type.to_s.empty? || subtype.to_s.empty? + + MediaRange.new( + type: type, + subtype: subtype, + quality: extract_quality(parameter_parts), + position: position ) end + + # @param parameter_parts [Array] + # @return [Float] + def extract_quality(parameter_parts) + raw_value = parameter_parts + .map(&:strip) + .find { |part| part.start_with?('q=') } + &.split('=', 2) + &.last + quality = raw_value ? Float(raw_value) : 1.0 + quality.clamp(0.0, 1.0) + rescue ArgumentError + 1.0 + end + + # @param media_ranges [Array] + # @param candidates [Array] + # @return [Array(Float, Integer, Integer), nil] + def best_score(media_ranges, candidates) + media_ranges + .filter { |range| range.quality.positive? && candidates.any? { |candidate| range.matches?(candidate) } } + .map { |range| [range.quality, range.specificity, -range.position] } + .max + end end end + # rubocop:enable Metrics/ModuleLength end end diff --git a/app/web/rendering/json_feed_builder.rb b/app/web/rendering/json_feed_builder.rb deleted file mode 100644 index 727c4b99..00000000 --- a/app/web/rendering/json_feed_builder.rb +++ /dev/null @@ -1,86 +0,0 @@ -# frozen_string_literal: true - -require 'json' -require 'time' -module Html2rss - module Web - ## - # Central JSON Feed rendering helpers. - module JsonFeedBuilder - VERSION_URL = 'https://jsonfeed.org/version/1.1' - - class << self - # @param message [String] - # @param title [String] - # @return [String] single-item JSON Feed error document. - def build_error_feed(message:, title: 'Error') - build_single_item_feed( - title:, - description: "Failed to generate feed: #{message}", - item: { - title:, - content_text: message - } - ) - end - - # @param url [String] - # @param strategy [String] - # @param site_title [String, nil] - # @return [String] JSON Feed warning document when extraction yields no content. - def build_empty_feed_warning(url:, strategy:, site_title: nil) - build_single_item_feed( - title: FeedNoticeText.empty_feed_title(site_title), - description: FeedNoticeText.empty_feed_description(url: url, strategy: strategy), - home_page_url: url, - item: empty_feed_item(url) - ) - end - - private - - # @param title [String] - # @param description [String] - # @param item [Hash{Symbol=>Object}] - # @param home_page_url [String, nil] - # @return [String] - def build_single_item_feed(title:, description:, item:, home_page_url: nil) - payload = { - version: VERSION_URL, - title: title, - home_page_url: home_page_url, - description: description, - items: [build_single_item(item)] - }.compact - - JSON.generate(payload) - end - - # @param item [Hash{Symbol=>Object}] - # @return [Hash{Symbol=>Object}] - def build_single_item(item) - timestamp = Time.now.utc.iso8601 - - { - id: item[:url] || "#{item[:title]}-#{timestamp}", - url: item[:url], - title: item[:title], - content_text: item[:content_text], - content_html: item[:content_html], - date_published: timestamp - }.compact - end - - # @param url [String] - # @return [Hash{Symbol=>String}] - def empty_feed_item(url) - { - title: 'Preview unavailable for this source', - content_text: FeedNoticeText.empty_feed_item(url: url), - url: url - } - end - end - end - end -end diff --git a/app/web/rendering/xml_builder.rb b/app/web/rendering/xml_builder.rb deleted file mode 100644 index d8986171..00000000 --- a/app/web/rendering/xml_builder.rb +++ /dev/null @@ -1,129 +0,0 @@ -# frozen_string_literal: true - -require 'rss' -require 'time' - -module Html2rss - module Web - ## - # Central RSS/XML rendering helpers. - # - # XML shaping is centralized so endpoints/services can return consistent feed - # output without duplicating channel/item boilerplate. - module XmlBuilder - class << self - # @param title [String] - # @param description [String] - # @param link [String, nil] - # @param items [ArrayObject}>] - # @param timestamp [Time, nil] - # @return [String] serialized RSS XML document. - def build_rss_feed(title:, description:, link: nil, items: [], timestamp: nil) - RSS::Maker.make('2.0') do |maker| - apply_stylesheets(maker) - build_channel(maker.channel, title:, description:, link:, timestamp:) - build_items(maker, items, default_timestamp: timestamp) - end.to_s - end - - # @param message [String] - # @param title [String] - # @return [String] single-item RSS error document. - def build_error_feed(message:, title: 'Error') - build_single_item_feed( - title:, - description: "Failed to generate feed: #{message}", - item: { - title:, - description: message - } - ) - end - - # @param url [String] - # @param strategy [String] - # @param site_title [String, nil] - # @return [String] RSS warning document when extraction yields no content. - def build_empty_feed_warning(url:, strategy:, site_title: nil) - build_single_item_feed( - title: FeedNoticeText.empty_feed_title(site_title), - description: FeedNoticeText.empty_feed_description(url: url, strategy: strategy), - item: { title: 'Preview unavailable for this source', description: FeedNoticeText.empty_feed_item(url: url), - link: url }, - link: url - ) - end - - private - - # @param maker [RSS::Maker::RSS20] - # @return [void] - def apply_stylesheets(maker) - # Use the gem's internal stylesheet support. - stylesheets = Html2rss.configuration.stylesheets.map do |s| - Html2rss::RssBuilder::Stylesheet.new(**s) - end - Html2rss::RssBuilder::Stylesheet.add(maker, stylesheets) - end - - # @param title [String] - # @param description [String] - # @param item [Hash{Symbol=>Object}] - # @param link [String, nil] - # @return [String] - def build_single_item_feed(title:, description:, item:, link: nil) - timestamp = Time.now - build_rss_feed( - title:, - description:, - link:, - items: [feed_item(item, timestamp:)], - timestamp: - ) - end - - # @param item [Hash{Symbol=>Object}] - # @param timestamp [Time] - # @return [Hash{Symbol=>Object}] normalized item with required RSS fields. - def feed_item(item, timestamp:) - { - title: item[:title], - description: item[:description], - link: item[:link], - pubDate: timestamp - } - end - - # @param channel [RSS::Maker::RSS20::Channel] - # @param title [String] - # @param description [String] - # @param link [String, nil] - # @param timestamp [Time, nil] - # @return [void] - def build_channel(channel, title:, description:, link:, timestamp:) - now = timestamp || Time.now - channel.title = title.to_s - channel.description = description.to_s - channel.link = link.to_s - channel.lastBuildDate = now - channel.pubDate = now - end - - # @param maker [RSS::Maker::RSS20] - # @param items [ArrayObject}>] - # @param default_timestamp [Time, nil] - # @return [void] - def build_items(maker, items, default_timestamp:) - items.each do |item| - maker.items.new_item do |i| - i.title = item[:title].to_s - i.description = item[:description].to_s - i.link = item[:link].to_s - i.pubDate = item[:pubDate] || default_timestamp || Time.now - end - end - end - end - end - end -end diff --git a/app/web/security/feed_token.rb b/app/web/security/feed_token.rb index eb4521fb..9353716a 100644 --- a/app/web/security/feed_token.rb +++ b/app/web/security/feed_token.rb @@ -1,12 +1,17 @@ # frozen_string_literal: true +require 'base64' +require 'json' +require 'zlib' +require 'openssl' + module Html2rss module Web ## # Immutable feed-token value object. # - # Wire encoding lives in {FeedToken::Codec}; HMAC creation and checks live - # in {FeedToken::Signer}. + # Wire encoding and signature verification are unified inside this class to + # keep the token lifetime concerns cohesive. FeedToken = Data.define(:username, :url, :expires_at, :signature, :strategy) do # @return [Boolean] def expired? @@ -20,6 +25,201 @@ def valid_for_url?(candidate_url) end end - FeedToken::DEFAULT_EXPIRY = 315_360_000 + class FeedToken + DEFAULT_EXPIRY = 315_360_000 + + ## + # zlib + URL-safe Base64 codec for feed-token wire payloads. + module Codec + class << self + # @param token [Html2rss::Web::FeedToken] + # @return [String] + def encode(token) + compressed = Zlib::Deflate.deflate(wire_document(token).to_json) + Base64.urlsafe_encode64(compressed) + end + + # @param encoded_token [String, nil] + # @return [Html2rss::Web::FeedToken, nil] + def decode(encoded_token) + return unless encoded_token + + token_data = parse_token_data(encoded_token) + return unless valid_token_data?(token_data) + + decoded_token(token_data) + rescue JSON::ParserError, ArgumentError, Zlib::DataError, Zlib::BufError + nil + end + + private + + # @param token [Html2rss::Web::FeedToken] + # @return [Hash{Symbol=>Object}] + def wire_document(token) + payload = { u: token.username, l: token.url, e: token.expires_at } + payload[:t] = token.strategy if token.strategy + { p: payload, s: token.signature } + end + + # @param encoded_token [String] + # @return [Hash{Symbol=>Object}] + def parse_token_data(encoded_token) + inflated = Zlib::Inflate.inflate(Base64.urlsafe_decode64(encoded_token)) + JSON.parse(inflated, symbolize_names: true) + end + + # @param token_data [Hash{Symbol=>Object}] + # @return [Html2rss::Web::FeedToken] + def decoded_token(token_data) + payload = token_data[:p] + FeedToken.new( + username: payload[:u], + url: payload[:l], + expires_at: payload[:e], + signature: token_data[:s], + strategy: payload[:t] + ) + end + + # @param token_data [Object] + # @return [Boolean] + def valid_token_data?(token_data) + return false unless token_data.is_a?(Hash) + + signature = token_data[:s] + signature.is_a?(String) && !signature.empty? && valid_payload?(token_data[:p]) + end + + # @param payload [Object] + # @return [Boolean] + def valid_payload?(payload) + payload.is_a?(Hash) && + payload[:u].is_a?(String) && + payload[:l].is_a?(String) && + payload[:e].is_a?(Integer) && + (payload[:t].nil? || payload[:t].is_a?(String)) + end + end + end + + ## + # HMAC-SHA256 creation and verification for feed tokens. + module Signer + HMAC_ALGORITHM = 'SHA256' + + class << self + # @param username [String] + # @param url [String] + # @param secret_key [String] + # @param strategy [String, nil] + # @param expires_in [Integer] + # @return [Html2rss::Web::FeedToken, nil] + def create(username:, url:, secret_key:, strategy: nil, expires_in: DEFAULT_EXPIRY) + return unless valid_inputs?(username, url, secret_key, strategy) + + expires_at = Time.now.to_i + expires_in.to_i + signature = sign(secret_key, signature_payload(username, url, expires_at, strategy)) + FeedToken.new(username:, url:, expires_at:, signature:, strategy:) + end + + # @param encoded_token [String, nil] + # @param expected_url [String, nil] + # @param secret_key [String] + # @return [Html2rss::Web::FeedToken, nil] + def validate(encoded_token, expected_url, secret_key) + validate_decoded(Codec.decode(encoded_token), expected_url, secret_key) + end + + # Validates signature, URL binding, and expiry for an already-decoded token. + # + # @param token [Html2rss::Web::FeedToken, nil] + # @param expected_url [String, nil] + # @param secret_key [String] + # @return [Html2rss::Web::FeedToken, nil] + def validate_decoded(token, expected_url, secret_key) + return unless token + return unless valid_signature?(token, secret_key) + return unless token.valid_for_url?(expected_url) + return if token.expired? + + token + end + + # @param token [Html2rss::Web::FeedToken] + # @param secret_key [String] + # @return [Boolean] + def valid_signature?(token, secret_key) + return false unless secret_key.is_a?(String) && !secret_key.empty? + + expected_signature = sign(secret_key, signature_payload( + token.username, token.url, token.expires_at, token.strategy + )) + signatures_match?(token.signature, expected_signature) + end + + private + + # @param username [String] + # @param url [String] + # @param expires_at [Integer] + # @param strategy [String, nil] + # @return [Hash{Symbol=>Object}] + def signature_payload(username, url, expires_at, strategy) + payload = { username:, url:, expires_at: } + payload[:strategy] = strategy if strategy + payload + end + + # @param secret_key [String] + # @param payload [Hash, String] + # @return [String] + def sign(secret_key, payload) + data = payload.is_a?(String) ? payload : JSON.generate(payload) + OpenSSL::HMAC.hexdigest(HMAC_ALGORITHM, secret_key, data) + end + + # @param first [String, nil] + # @param second [String, nil] + # @return [Boolean] + def signatures_match?(first, second) + return false unless first && second && first.bytesize == second.bytesize + + first.each_byte.zip(second.each_byte).reduce(0) { |acc, (a, b)| acc | (a ^ b) }.zero? + end + + # @param username [Object] + # @param url [Object] + # @param secret_key [Object] + # @param strategy [Object] + # @return [Boolean] + def valid_inputs?(username, url, secret_key, strategy) + valid_username?(username) && UrlValidator.valid_url?(url) && valid_secret_key?(secret_key) && + valid_strategy?(strategy) + end + + # @param username [Object] + # @return [Boolean] + def valid_username?(username) + username.is_a?(String) && !username.empty? && username.length <= 100 && + username.match?(/\A[a-zA-Z0-9_-]+\z/) + end + + # @param secret_key [Object] + # @return [Boolean] + def valid_secret_key?(secret_key) + secret_key.is_a?(String) && !secret_key.empty? + end + + # @param strategy [Object] + # @return [Boolean] + def valid_strategy?(strategy) + return true if strategy.nil? + + strategy.is_a?(String) && !strategy.empty? && strategy.length <= 50 && strategy.match?(/\A[a-z0-9_]+\z/) + end + end + end + end end end diff --git a/app/web/security/feed_token/codec.rb b/app/web/security/feed_token/codec.rb deleted file mode 100644 index 2be46d9e..00000000 --- a/app/web/security/feed_token/codec.rb +++ /dev/null @@ -1,88 +0,0 @@ -# frozen_string_literal: true - -require 'base64' -require 'json' -require 'zlib' - -module Html2rss - module Web - class FeedToken - ## - # zlib + URL-safe Base64 codec for feed-token wire payloads. - # - # Wire shape is JSON +zlib+base64 of +{ p: { u, l, e, t? }, s }+. - module Codec - class << self - # @param token [Html2rss::Web::FeedToken] - # @return [String] - def encode(token) - compressed = Zlib::Deflate.deflate(wire_document(token).to_json) - Base64.urlsafe_encode64(compressed) - end - - # @param encoded_token [String, nil] - # @return [Html2rss::Web::FeedToken, nil] - def decode(encoded_token) - return unless encoded_token - - token_data = parse_token_data(encoded_token) - return unless valid_token_data?(token_data) - - decoded_token(token_data) - rescue JSON::ParserError, ArgumentError, Zlib::DataError, Zlib::BufError - nil - end - - private - - # @param token [Html2rss::Web::FeedToken] - # @return [Hash{Symbol=>Object}] - def wire_document(token) - payload = { u: token.username, l: token.url, e: token.expires_at } - payload[:t] = token.strategy if token.strategy - { p: payload, s: token.signature } - end - - # @param encoded_token [String] - # @return [Hash{Symbol=>Object}] - def parse_token_data(encoded_token) - inflated = Zlib::Inflate.inflate(Base64.urlsafe_decode64(encoded_token)) - JSON.parse(inflated, symbolize_names: true) - end - - # @param token_data [Hash{Symbol=>Object}] - # @return [Html2rss::Web::FeedToken] - def decoded_token(token_data) - payload = token_data[:p] - FeedToken.new( - username: payload[:u], - url: payload[:l], - expires_at: payload[:e], - signature: token_data[:s], - strategy: payload[:t] - ) - end - - # @param token_data [Object] - # @return [Boolean] - def valid_token_data?(token_data) - return false unless token_data.is_a?(Hash) - - signature = token_data[:s] - signature.is_a?(String) && !signature.empty? && valid_payload?(token_data[:p]) - end - - # @param payload [Object] - # @return [Boolean] - def valid_payload?(payload) - payload.is_a?(Hash) && - payload[:u].is_a?(String) && - payload[:l].is_a?(String) && - payload[:e].is_a?(Integer) && - (payload[:t].nil? || payload[:t].is_a?(String)) - end - end - end - end - end -end diff --git a/app/web/security/feed_token/signer.rb b/app/web/security/feed_token/signer.rb deleted file mode 100644 index d5fae6e2..00000000 --- a/app/web/security/feed_token/signer.rb +++ /dev/null @@ -1,128 +0,0 @@ -# frozen_string_literal: true - -require 'json' -require 'openssl' - -module Html2rss - module Web - class FeedToken - ## - # HMAC-SHA256 creation and verification for feed tokens. - module Signer - HMAC_ALGORITHM = 'SHA256' - - class << self - # @param username [String] - # @param url [String] - # @param secret_key [String] - # @param strategy [String, nil] - # @param expires_in [Integer] - # @return [Html2rss::Web::FeedToken, nil] - def create(username:, url:, secret_key:, strategy: nil, expires_in: FeedToken::DEFAULT_EXPIRY) - return unless valid_inputs?(username, url, secret_key, strategy) - - expires_at = Time.now.to_i + expires_in.to_i - signature = sign(secret_key, signature_payload(username, url, expires_at, strategy)) - FeedToken.new(username:, url:, expires_at:, signature:, strategy:) - end - - # @param encoded_token [String, nil] - # @param expected_url [String, nil] - # @param secret_key [String] - # @return [Html2rss::Web::FeedToken, nil] - def validate(encoded_token, expected_url, secret_key) - validate_decoded(Codec.decode(encoded_token), expected_url, secret_key) - end - - # Validates signature, URL binding, and expiry for an already-decoded token. - # - # @param token [Html2rss::Web::FeedToken, nil] - # @param expected_url [String, nil] - # @param secret_key [String] - # @return [Html2rss::Web::FeedToken, nil] - def validate_decoded(token, expected_url, secret_key) - return unless token - return unless valid_signature?(token, secret_key) - return unless token.valid_for_url?(expected_url) - return if token.expired? - - token - end - - # @param token [Html2rss::Web::FeedToken] - # @param secret_key [String] - # @return [Boolean] - def valid_signature?(token, secret_key) - return false unless secret_key.is_a?(String) && !secret_key.empty? - - expected_signature = sign(secret_key, signature_payload( - token.username, token.url, token.expires_at, token.strategy - )) - signatures_match?(token.signature, expected_signature) - end - - private - - # @param username [String] - # @param url [String] - # @param expires_at [Integer] - # @param strategy [String, nil] - # @return [Hash{Symbol=>Object}] - def signature_payload(username, url, expires_at, strategy) - payload = { username:, url:, expires_at: } - payload[:strategy] = strategy if strategy - payload - end - - # @param secret_key [String] - # @param payload [Hash, String] - # @return [String] - def sign(secret_key, payload) - data = payload.is_a?(String) ? payload : JSON.generate(payload) - OpenSSL::HMAC.hexdigest(HMAC_ALGORITHM, secret_key, data) - end - - # @param first [String, nil] - # @param second [String, nil] - # @return [Boolean] - def signatures_match?(first, second) - return false unless first && second && first.bytesize == second.bytesize - - first.each_byte.zip(second.each_byte).reduce(0) { |acc, (a, b)| acc | (a ^ b) }.zero? - end - - # @param username [Object] - # @param url [Object] - # @param secret_key [Object] - # @param strategy [Object] - # @return [Boolean] - def valid_inputs?(username, url, secret_key, strategy) - valid_username?(username) && UrlValidator.valid_url?(url) && valid_secret_key?(secret_key) && - valid_strategy?(strategy) - end - - # @param username [Object] - # @return [Boolean] - def valid_username?(username) - username.is_a?(String) && !username.empty? && username.length <= 100 && - username.match?(/\A[a-zA-Z0-9_-]+\z/) - end - - # @param secret_key [Object] - # @return [Boolean] - def valid_secret_key?(secret_key) - secret_key.is_a?(String) && !secret_key.empty? - end - - # @param strategy [Object] - # @return [Boolean] - def valid_strategy?(strategy) - return true if strategy.nil? - - strategy.is_a?(String) && !strategy.empty? && strategy.length <= 50 && strategy.match?(/\A[a-z0-9_]+\z/) - end - end - end - end - end -end diff --git a/frontend/src/__tests__/feedCreationError.test.ts b/frontend/src/__tests__/feedCreationError.test.ts index ce87b14e..ba855d5e 100644 --- a/frontend/src/__tests__/feedCreationError.test.ts +++ b/frontend/src/__tests__/feedCreationError.test.ts @@ -3,7 +3,7 @@ import { buildLocalError, normalizeFeedCreationError, normalizeFeedCreationErrorFromResponse, -} from '../feeds/feedCreationError'; +} from '../feeds/feedsService'; describe('feedCreationError', () => { it('normalizes structured response envelopes without parsing message text', () => { diff --git a/frontend/src/__tests__/previewHydration.test.ts b/frontend/src/__tests__/previewHydration.test.ts index a41a6dbe..be6d1a1b 100644 --- a/frontend/src/__tests__/previewHydration.test.ts +++ b/frontend/src/__tests__/previewHydration.test.ts @@ -4,7 +4,7 @@ import { loadPreviewItems, loadPreviewItemsWithRetry, normalizePreviewItems, -} from '../feeds/previewHydration'; +} from '../feeds/feedsService'; const feed = { id: 'feed-1', diff --git a/frontend/src/components/App.tsx b/frontend/src/components/App.tsx index 565b40a9..fe68562b 100644 --- a/frontend/src/components/App.tsx +++ b/frontend/src/components/App.tsx @@ -1,19 +1,8 @@ -import { useEffect, useRef, useState } from 'preact/hooks'; import type { JSX } from 'preact'; import { ResultDisplay } from './ResultDisplay'; import { CreateFeedPanel, UtilityStrip } from './AppPanels'; import { Notice } from './Notice'; -import { useAccessToken } from '../hooks/useAccessToken'; -import { useApiMetadata } from '../hooks/useApiMetadata'; -import { useFeedConversion } from '../hooks/useFeedConversion'; -import { useAppRoute } from '../routes/appRoute'; -import { clearFeedDraftState, loadFeedDraftState, saveFeedDraftState } from '../utils/feedWorkflowStorage'; -import { normalizeUserUrl } from '../utils/url'; -import { deriveAppViewModel } from '../appViewModel'; -import type { FeedCreationError } from '../api/contracts'; - -const EMPTY_FEED_ERRORS = { url: '', form: '' }; -const DEFAULT_FEED_CREATION = { enabled: true, access_token_required: true }; +import { useAppPresenter } from '../hooks/useAppPresenter'; function BrandLockup({ onNavigateHome }: { onNavigateHome: () => void }) { return ( @@ -37,192 +26,35 @@ function BrandLockup({ onNavigateHome }: { onNavigateHome: () => void }) { } export function App() { - const { route, navigate } = useAppRoute(); - const { - token, - hasToken, - saveToken, - clearToken, - isLoading: tokenLoading, - error: tokenStateError, - } = useAccessToken(); - const { metadata, isLoading: metadataLoading, error: metadataError } = useApiMetadata(); const { - isConverting, - result, - error: conversionError, - convertFeed, - clearError, - clearResult, - retryPreviewFetch, - } = useFeedConversion(); - - const [feedFormData, setFeedFormData] = useState(() => loadFeedDraftState() ?? { url: '' }); - const [feedFieldErrors, setFeedFieldErrors] = useState(EMPTY_FEED_ERRORS); - const [tokenDraft, setTokenDraft] = useState(''); - const [tokenError, setTokenError] = useState(''); - const [bookmarkletNotice, setBookmarkletNotice] = useState(''); - const [focusCreateComposerKey, setFocusCreateComposerKey] = useState(0); - const autoSubmitUrlReference = useRef(route.prefillUrl); - const hasAutoSubmittedReference = useRef(false); - const isTokenRoute = route.kind === 'token'; - const activeResult = - route.kind === 'result' && result?.feed.feed_token === route.feedToken ? result : undefined; - let visibleRouteKind: 'create' | 'token' | 'result' = 'create'; - if (activeResult) { - visibleRouteKind = 'result'; - } else if (isTokenRoute) { - visibleRouteKind = 'token'; - } - const viewModel = deriveAppViewModel({ - conversionError, + viewModel, + feedFormData, feedFieldErrors, - isConverting, - routeKind: visibleRouteKind, - tokenError, - tokenStateError, - metadataError, - result: activeResult, - }); - - useEffect(() => { - if (!route.prefillUrl) return; - autoSubmitUrlReference.current = route.prefillUrl; - if (feedFormData.url) return; - - setFeedFormData((previous) => ({ ...previous, url: route.prefillUrl ?? previous.url })); - }, [feedFormData.url, route.prefillUrl]); - - const feedCreation = metadata?.instance.feed_creation ?? DEFAULT_FEED_CREATION; - const featuredFeeds = metadata?.instance.featured_feeds ?? []; - const submitDisabled = isConverting || !feedCreation.enabled || viewModel.kind === 'token_prompt'; - - const setFeedField = (key: 'url', value: string) => { - setFeedFormData((previous) => { - const next = { ...previous, [key]: value }; - if (next.url.trim()) { - saveFeedDraftState(next); - } else { - clearFeedDraftState(); - } - return next; - }); - setFeedFieldErrors((previous) => ({ ...previous, url: '', form: '' })); - clearError(); - }; - - const attemptFeedCreation = async (accessToken: string) => { - const normalizedUrl = normalizeUserUrl(feedFormData.url); - - if (!normalizedUrl) { - setFeedFieldErrors({ ...EMPTY_FEED_ERRORS, url: 'Source URL is required.' }); - return false; - } - - if (!feedCreation.enabled) { - setFeedFieldErrors({ - ...EMPTY_FEED_ERRORS, - form: 'Feed creation is disabled on this instance.', - }); - return false; - } - - if (feedCreation.access_token_required && !accessToken) { - setFeedFormData((previous) => ({ ...previous, url: normalizedUrl })); - clearError(); - setTokenError(''); - if (route.kind !== 'token') navigate({ kind: 'token', prefillUrl: normalizedUrl }); - return false; - } - - try { - setFeedFormData((previous) => ({ ...previous, url: normalizedUrl })); - const createdResult = await convertFeed(normalizedUrl, accessToken); - clearFeedDraftState(); - navigate({ kind: 'result', feedToken: createdResult.feed.feed_token }); - setTokenError(''); - return true; - } catch (submitError) { - const failure = submitError as FeedCreationError; - - if (failure.kind === 'auth' || failure.nextAction === 'enter_token') { - clearToken(); - clearError(); - setTokenDraft(''); - if (route.kind !== 'token') navigate({ kind: 'token', prefillUrl: normalizedUrl }); - setTokenError('Access token was rejected. Paste a valid token to continue.'); - setFeedFieldErrors(EMPTY_FEED_ERRORS); - return false; - } - - if (failure.nextAction === 'correct_input') { - setFeedFieldErrors({ ...EMPTY_FEED_ERRORS, form: failure.message }); - return false; - } - - setFeedFieldErrors({ ...EMPTY_FEED_ERRORS, form: failure.message }); - return false; - } - }; - - const handleFeedSubmit = async (event: Event) => { - event.preventDefault(); - setFeedFieldErrors(EMPTY_FEED_ERRORS); - await attemptFeedCreation(token ?? ''); - }; - - const handleSaveToken = async () => { - try { - const normalizedToken = tokenDraft.trim(); - await saveToken(normalizedToken); - setTokenError(''); - const created = await attemptFeedCreation(normalizedToken); - if (created) setTokenDraft(''); - } catch (error) { - setTokenError(error instanceof Error ? error.message : 'Unable to save access token.'); - } - }; - - const handleCreateAnother = () => { - clearResult(); - setFocusCreateComposerKey((current) => current + 1); - navigate({ kind: 'create', prefillUrl: feedFormData.url || undefined }); - }; - - const handleRetryCreation = () => { - setFeedFieldErrors(EMPTY_FEED_ERRORS); - clearError(); - void attemptFeedCreation(token ?? ''); - }; - - useEffect(() => { - const autoSubmitUrl = autoSubmitUrlReference.current; - if (!autoSubmitUrl || hasAutoSubmittedReference.current) return; - if (metadataLoading || tokenLoading) return; - if (feedFormData.url !== autoSubmitUrl) return; - - if (feedCreation.access_token_required && !token) { - hasAutoSubmittedReference.current = true; - setFeedFormData((previous) => ({ ...previous, url: normalizeUserUrl(autoSubmitUrl) })); - setTokenError(''); - if (route.kind !== 'token') { - navigate({ kind: 'token', prefillUrl: normalizeUserUrl(autoSubmitUrl) }); - } - return; - } - - hasAutoSubmittedReference.current = true; - setFeedFieldErrors(EMPTY_FEED_ERRORS); - void attemptFeedCreation(token ?? ''); - }, [ - feedCreation.access_token_required, - feedFormData.url, + tokenDraft, + bookmarkletNotice, + focusCreateComposerKey, + metadata, + hasToken, + submitDisabled, + feedCreationEnabled, + featuredFeeds, metadataLoading, - navigate, - route.kind, - token, tokenLoading, - ]); + metadataError, + tokenStateError, + onFeedSubmit, + onFeedFieldChange, + onTokenDraftChange, + onSaveToken, + onCancelTokenPrompt, + onRetryCreate, + onCreateAnother, + onRetryPreview, + onClearToken, + onShowBookmarkletHelp, + setBookmarkletNotice, + navigate, + } = useAppPresenter(); let bodyContent: JSX.Element; if (metadataLoading || tokenLoading) { @@ -235,8 +67,8 @@ export function App() { bodyContent = ( ); } else { @@ -247,23 +79,15 @@ export function App() { feedFormData={feedFormData} feedFieldErrors={feedFieldErrors} submitDisabled={submitDisabled} - feedCreationEnabled={feedCreation.enabled} + feedCreationEnabled={feedCreationEnabled} featuredFeeds={featuredFeeds} tokenDraft={tokenDraft} - onFeedSubmit={handleFeedSubmit} - onFeedFieldChange={setFeedField} - onTokenDraftChange={(value) => { - setTokenDraft(value); - setTokenError(''); - clearError(); - }} - onSaveToken={handleSaveToken} - onCancelTokenPrompt={() => { - setTokenError(''); - clearError(); - navigate({ kind: 'create', prefillUrl: feedFormData.url || undefined }); - }} - onRetryCreate={handleRetryCreation} + onFeedSubmit={onFeedSubmit} + onFeedFieldChange={onFeedFieldChange} + onTokenDraftChange={onTokenDraftChange} + onSaveToken={onSaveToken} + onCancelTokenPrompt={onCancelTokenPrompt} + onRetryCreate={onRetryCreate} /> ); } @@ -314,12 +138,8 @@ export function App() { { - clearToken(); - clearError(); - navigate({ kind: 'create', prefillUrl: feedFormData.url || undefined }); - }} - onShowBookmarkletHelp={() => setBookmarkletNotice('show')} + onClearToken={onClearToken} + onShowBookmarkletHelp={onShowBookmarkletHelp} /> diff --git a/frontend/src/feeds/feedCreationError.ts b/frontend/src/feeds/feedCreationError.ts deleted file mode 100644 index 3301f1f0..00000000 --- a/frontend/src/feeds/feedCreationError.ts +++ /dev/null @@ -1,206 +0,0 @@ -import type { FeedCreationError, FeedNextAction, FeedRetryAction } from '../api/contracts'; -import { isTransientHttpStatus, normalizeBoolean, normalizeString } from './shared'; - -export interface RawErrorEnvelope { - kind?: unknown; - code?: unknown; - retryable?: unknown; - next_action?: unknown; - retry_action?: unknown; - message?: unknown; -} - -export interface RawApiResponse { - success?: unknown; - data?: unknown; - error?: unknown; -} - -export function normalizeFeedCreationError(error: unknown): FeedCreationError { - if (isFeedCreationError(error)) return error; - - if (error instanceof Error) { - return buildStructuredError( - 'network', - 'NETWORK_ERROR', - true, - 'retry', - 'primary', - error.message || 'Unable to reach the server.' - ); - } - - return buildStructuredError( - 'server', - 'UNKNOWN_ERROR', - true, - 'retry', - 'primary', - 'Unable to complete feed creation.' - ); -} - -export function normalizeFeedCreationErrorFromResponse( - status: number, - errorPayload: unknown, - payload?: RawApiResponse -): FeedCreationError { - const envelope = resolveErrorEnvelope(errorPayload, payload); - - const kind = normalizeErrorKind(envelope?.kind, status); - const isRetryable = normalizeBoolean(envelope?.retryable, defaultRetryableFromStatus(status, kind)); - const nextAction = normalizeNextAction(envelope?.next_action, kind, isRetryable); - const retryAction = normalizeRetryAction(envelope?.retry_action, nextAction, isRetryable); - const code = normalizeString(envelope?.code) || fallbackErrorCode(status, kind); - const message = normalizeString(envelope?.message) || fallbackErrorMessage(status, kind, nextAction); - - return buildStructuredError(kind, code, isRetryable, nextAction, retryAction, message, status); -} - -export function buildStructuredError( - kind: FeedCreationError['kind'], - code: string, - // eslint-disable-next-line unicorn/consistent-boolean-name - retryable: boolean, - nextAction: FeedNextAction, - retryAction: FeedRetryAction, - message: string, - status?: number -): FeedCreationError { - return { - kind, - code, - retryable, - nextAction, - retryAction, - message, - ...(typeof status === 'number' && { status }), - }; -} - -export function buildLocalError( - message: string, - kind: FeedCreationError['kind'], - nextAction: FeedNextAction -): FeedCreationError { - const isRetryable = nextAction === 'retry'; - return buildStructuredError( - kind, - localErrorCode(kind, nextAction), - isRetryable, - nextAction, - isRetryable ? 'primary' : 'none', - message - ); -} - -function resolveErrorEnvelope(errorPayload: unknown, payload?: RawApiResponse): RawErrorEnvelope | undefined { - if (isErrorEnvelope(errorPayload)) return errorPayload; - if (isErrorEnvelope(payload?.error)) return payload.error; - if (isErrorEnvelope(payload)) return payload; - return undefined; -} - -function normalizeNextAction( - value: unknown, - kind: FeedCreationError['kind'], - // eslint-disable-next-line unicorn/consistent-boolean-name - retryable: boolean -): FeedNextAction { - if ((['enter_token', 'correct_input', 'retry', 'wait', 'none'] as unknown[]).includes(value)) { - return value as FeedNextAction; - } - - if (kind === 'auth') return 'enter_token'; - if (kind === 'input') return 'correct_input'; - if (retryable) return 'retry'; - return 'none'; -} - -function normalizeRetryAction( - value: unknown, - nextAction: FeedNextAction, - // eslint-disable-next-line unicorn/consistent-boolean-name - retryable: boolean -): FeedRetryAction { - if ((['alternate', 'primary', 'none'] as unknown[]).includes(value)) { - return value as FeedRetryAction; - } - - if (!retryable || nextAction !== 'retry') return 'none'; - return 'primary'; -} - -function normalizeErrorKind(value: unknown, status: number): FeedCreationError['kind'] { - if ((['auth', 'input', 'network', 'server'] as unknown[]).includes(value)) - return value as FeedCreationError['kind']; - - if (status === 401 || status === 403) return 'auth'; - if ([400, 404, 422].includes(status)) return 'input'; - if (isTransientHttpStatus(status)) return 'network'; - return 'server'; -} - -// eslint-disable-next-line unicorn/consistent-boolean-name -function defaultRetryableFromStatus(status: number, kind: FeedCreationError['kind']): boolean { - if (kind === 'auth' || kind === 'input') return false; - if (kind === 'network') return true; - return isTransientHttpStatus(status) || status >= 500; -} - -function fallbackErrorCode(status: number, kind: FeedCreationError['kind']): string { - if (status === 401) return 'AUTH_REQUIRED'; - if (status === 403) return 'AUTH_FORBIDDEN'; - if (status === 400) return 'INVALID_INPUT'; - if (status === 404) return 'NOT_FOUND'; - if (status === 422) return 'UNPROCESSABLE_INPUT'; - if (isTransientHttpStatus(status)) return 'TRANSIENT_ERROR'; - if (status >= 500) return 'SERVER_ERROR'; - return `${kind.toUpperCase()}_ERROR`; -} - -function fallbackErrorMessage( - status: number, - kind: FeedCreationError['kind'], - nextAction: FeedNextAction -): string { - if (kind === 'auth') return 'Access token is required.'; - if (kind === 'input') return 'Check the URL and try again.'; - if (nextAction === 'wait') return 'The server is still processing the request.'; - if (isTransientHttpStatus(status) || kind === 'network') return 'Unable to reach the server. Try again.'; - return 'Unable to complete feed creation.'; -} - -function localErrorCode(kind: FeedCreationError['kind'], nextAction: FeedNextAction): string { - if (kind === 'auth') return 'AUTH_REQUIRED'; - if (kind === 'input' && nextAction === 'correct_input') return 'INVALID_INPUT'; - return 'LOCAL_VALIDATION_ERROR'; -} - -function isFeedCreationError(value: unknown): value is FeedCreationError { - if (!value || typeof value !== 'object') return false; - - const candidate = value as Partial; - return ( - (['auth', 'input', 'network', 'server'] as unknown[]).includes(candidate.kind) && - typeof candidate.code === 'string' && - typeof candidate.retryable === 'boolean' && - typeof candidate.nextAction === 'string' && - typeof candidate.retryAction === 'string' && - typeof candidate.message === 'string' - ); -} - -function isErrorEnvelope(value: unknown): value is RawErrorEnvelope { - if (!value || typeof value !== 'object') return false; - - const candidate = value as RawErrorEnvelope; - return ( - candidate.kind !== undefined || - candidate.code !== undefined || - candidate.retryable !== undefined || - candidate.next_action !== undefined || - candidate.retry_action !== undefined || - candidate.message !== undefined - ); -} diff --git a/frontend/src/feeds/feedsApi.ts b/frontend/src/feeds/feedsApi.ts deleted file mode 100644 index 05cb9cfd..00000000 --- a/frontend/src/feeds/feedsApi.ts +++ /dev/null @@ -1,95 +0,0 @@ -import type { FeedRecord } from '../api/contracts'; -import { - buildStructuredError, - normalizeFeedCreationErrorFromResponse, - type RawApiResponse, -} from './feedCreationError'; -import { normalizeString, readJsonResponse } from './shared'; - -interface RawFeedRecord { - id?: unknown; - name?: unknown; - url?: unknown; - feed_token?: unknown; - public_url?: unknown; - json_public_url?: unknown; - created_at?: unknown; - updated_at?: unknown; -} - -interface RawFeedPayload { - feed?: RawFeedRecord; -} - -interface RawFeedApiResponse extends RawApiResponse { - data?: RawFeedPayload; -} - -export async function requestFeedCreation(url: string, token: string): Promise { - const response = await fetch(resolveApiUrl('feeds'), { - method: 'POST', - headers: buildCreateHeaders(token), - body: JSON.stringify({ url }), - }); - - const payload = await readJsonResponse(response); - - if (!response.ok) { - throw normalizeFeedCreationErrorFromResponse(response.status, payload?.error, payload); - } - - const feed = normalizeFeedRecord(payload?.data?.feed); - if (!feed) { - throw buildStructuredError( - 'server', - 'INVALID_RESPONSE', - true, - 'retry', - 'primary', - 'Unable to start feed generation.', - response.status - ); - } - - return feed; -} - -export function normalizeFeedRecord(raw?: RawFeedRecord): FeedRecord | undefined { - if (!raw) return undefined; - - const feedToken = normalizeString(raw.feed_token); - const publicUrl = normalizeString(raw.public_url); - const jsonPublicUrl = normalizeString(raw.json_public_url); - const url = normalizeString(raw.url); - - if (!feedToken || !publicUrl || !jsonPublicUrl || !url) return undefined; - - return { - id: normalizeString(raw.id) || feedToken, - name: normalizeString(raw.name) || url, - url, - feed_token: feedToken, - public_url: publicUrl, - json_public_url: jsonPublicUrl, - created_at: normalizeString(raw.created_at) || new Date().toISOString(), - updated_at: normalizeString(raw.updated_at) || new Date().toISOString(), - }; -} - -export function resolveApiUrl(path: string): string { - return `/api/v1/${path.replace(/^\/+/, '')}`; -} - -export function buildCreateHeaders(token: string): HeadersInit { - const normalizedToken = token.trim(); - const headers: Record = { - Accept: 'application/json', - 'Content-Type': 'application/json', - }; - - if (normalizedToken) { - headers.Authorization = `Bearer ${normalizedToken}`; - } - - return headers; -} diff --git a/frontend/src/feeds/feedsService.ts b/frontend/src/feeds/feedsService.ts new file mode 100644 index 00000000..fe20b59e --- /dev/null +++ b/frontend/src/feeds/feedsService.ts @@ -0,0 +1,556 @@ +import type { + CreatedFeedResult, + FeedCreationError, + FeedPreviewItem, + FeedPreviewStatus, + FeedPreviewWarning, + FeedNextAction, + FeedRetryAction, + FeedRecord, +} from '../api/contracts'; + +// ========================================== +// 1. Shared Utilities & Helpers +// ========================================== + +export function normalizeString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value.trim() : undefined; +} + +// eslint-disable-next-line unicorn/consistent-boolean-name +export function normalizeBoolean(value: unknown, fallback: boolean): boolean { + return typeof value === 'boolean' ? value : fallback; +} + +export function isTransientHttpStatus(status: number): boolean { + return [408, 409, 425, 429, 500, 502, 503, 504].includes(status); +} + +export function isAbortError(error: unknown): boolean { + return ( + (error instanceof DOMException && error.name === 'AbortError') || + (error instanceof Error && error.name === 'AbortError') + ); +} + +export async function wait(delayMs: number, signal?: AbortSignal): Promise { + if (delayMs <= 0) return; + + await new Promise((resolve, reject) => { + const timeoutHandle = setTimeout(() => { + signal?.removeEventListener('abort', onAbort); + resolve(); + }, delayMs); + + const onAbort = () => { + clearTimeout(timeoutHandle); + reject(new DOMException('Aborted', 'AbortError')); + }; + + if (signal) { + if (signal.aborted) { + clearTimeout(timeoutHandle); + reject(new DOMException('Aborted', 'AbortError')); + return; + } + + signal.addEventListener('abort', onAbort, { once: true }); + } + }); +} + +export function resolveFetchUrl(url: string): string { + if (/^https?:\/\//i.test(url)) return url; + const origin = globalThis.location?.origin ?? 'http://localhost'; + return new URL(url, origin).href; +} + +export async function readJsonResponse(response: Response): Promise { + const bodyText = await response.text(); + if (!bodyText.trim()) return undefined; + + try { + return JSON.parse(bodyText) as T; + } catch { + return undefined; + } +} + +// ========================================== +// 2. Feed Creation Error Handling +// ========================================== + +export interface RawErrorEnvelope { + kind?: unknown; + code?: unknown; + retryable?: unknown; + next_action?: unknown; + retry_action?: unknown; + message?: unknown; +} + +export interface RawApiResponse { + success?: unknown; + data?: unknown; + error?: unknown; +} + +export function normalizeFeedCreationError(error: unknown): FeedCreationError { + if (isFeedCreationError(error)) return error; + + if (error instanceof Error) { + return buildStructuredError( + 'network', + 'NETWORK_ERROR', + true, + 'retry', + 'primary', + error.message || 'Unable to reach the server.' + ); + } + + return buildStructuredError( + 'server', + 'UNKNOWN_ERROR', + true, + 'retry', + 'primary', + 'Unable to complete feed creation.' + ); +} + +export function normalizeFeedCreationErrorFromResponse( + status: number, + errorPayload: unknown, + payload?: RawApiResponse +): FeedCreationError { + const envelope = resolveErrorEnvelope(errorPayload, payload); + + const kind = normalizeErrorKind(envelope?.kind, status); + const isRetryable = normalizeBoolean(envelope?.retryable, defaultRetryableFromStatus(status, kind)); + const nextAction = normalizeNextAction(envelope?.next_action, kind, isRetryable); + const retryAction = normalizeRetryAction(envelope?.retry_action, nextAction, isRetryable); + const code = normalizeString(envelope?.code) || fallbackErrorCode(status, kind); + const message = normalizeString(envelope?.message) || fallbackErrorMessage(status, kind, nextAction); + + return buildStructuredError(kind, code, isRetryable, nextAction, retryAction, message, status); +} + +export function buildStructuredError( + kind: FeedCreationError['kind'], + code: string, + // eslint-disable-next-line unicorn/consistent-boolean-name + retryable: boolean, + nextAction: FeedNextAction, + retryAction: FeedRetryAction, + message: string, + status?: number +): FeedCreationError { + return { + kind, + code, + retryable, + nextAction, + retryAction, + message, + ...(typeof status === 'number' && { status }), + }; +} + +export function buildLocalError( + message: string, + kind: FeedCreationError['kind'], + nextAction: FeedNextAction +): FeedCreationError { + const isRetryable = nextAction === 'retry'; + return buildStructuredError( + kind, + localErrorCode(kind, nextAction), + isRetryable, + nextAction, + isRetryable ? 'primary' : 'none', + message + ); +} + +function resolveErrorEnvelope(errorPayload: unknown, payload?: RawApiResponse): RawErrorEnvelope | undefined { + if (isErrorEnvelope(errorPayload)) return errorPayload; + if (isErrorEnvelope(payload?.error)) return payload.error; + if (isErrorEnvelope(payload)) return payload; + return undefined; +} + +function normalizeNextAction( + value: unknown, + kind: FeedCreationError['kind'], + // eslint-disable-next-line unicorn/consistent-boolean-name + retryable: boolean +): FeedNextAction { + if ((['enter_token', 'correct_input', 'retry', 'wait', 'none'] as unknown[]).includes(value)) { + return value as FeedNextAction; + } + + if (kind === 'auth') return 'enter_token'; + if (kind === 'input') return 'correct_input'; + if (retryable) return 'retry'; + return 'none'; +} + +function normalizeRetryAction( + value: unknown, + nextAction: FeedNextAction, + // eslint-disable-next-line unicorn/consistent-boolean-name + retryable: boolean +): FeedRetryAction { + if ((['alternate', 'primary', 'none'] as unknown[]).includes(value)) { + return value as FeedRetryAction; + } + + if (!retryable || nextAction !== 'retry') return 'none'; + return 'primary'; +} + +function normalizeErrorKind(value: unknown, status: number): FeedCreationError['kind'] { + if ((['auth', 'input', 'network', 'server'] as unknown[]).includes(value)) + return value as FeedCreationError['kind']; + + if (status === 401 || status === 403) return 'auth'; + if ([400, 404, 422].includes(status)) return 'input'; + if (isTransientHttpStatus(status)) return 'network'; + return 'server'; +} + +// eslint-disable-next-line unicorn/consistent-boolean-name +function defaultRetryableFromStatus(status: number, kind: FeedCreationError['kind']): boolean { + if (kind === 'auth' || kind === 'input') return false; + if (kind === 'network') return true; + return isTransientHttpStatus(status) || status >= 500; +} + +function fallbackErrorCode(status: number, kind: FeedCreationError['kind']): string { + if (status === 401) return 'AUTH_REQUIRED'; + if (status === 403) return 'AUTH_FORBIDDEN'; + if (status === 400) return 'INVALID_INPUT'; + if (status === 404) return 'NOT_FOUND'; + if (status === 422) return 'UNPROCESSABLE_INPUT'; + if (isTransientHttpStatus(status)) return 'TRANSIENT_ERROR'; + if (status >= 500) return 'SERVER_ERROR'; + return `${kind.toUpperCase()}_ERROR`; +} + +function fallbackErrorMessage( + status: number, + kind: FeedCreationError['kind'], + nextAction: FeedNextAction +): string { + if (kind === 'auth') return 'Access token is required.'; + if (kind === 'input') return 'Check the URL and try again.'; + if (nextAction === 'wait') return 'The server is still processing the request.'; + if (isTransientHttpStatus(status) || kind === 'network') return 'Unable to reach the server. Try again.'; + return 'Unable to complete feed creation.'; +} + +function localErrorCode(kind: FeedCreationError['kind'], nextAction: FeedNextAction): string { + if (kind === 'auth') return 'AUTH_REQUIRED'; + if (kind === 'input' && nextAction === 'correct_input') return 'INVALID_INPUT'; + return 'LOCAL_VALIDATION_ERROR'; +} + +function isFeedCreationError(value: unknown): value is FeedCreationError { + if (!value || typeof value !== 'object') return false; + + const candidate = value as Partial; + return ( + (['auth', 'input', 'network', 'server'] as unknown[]).includes(candidate.kind) && + typeof candidate.code === 'string' && + typeof candidate.retryable === 'boolean' && + typeof candidate.nextAction === 'string' && + typeof candidate.retryAction === 'string' && + typeof candidate.message === 'string' + ); +} + +function isErrorEnvelope(value: unknown): value is RawErrorEnvelope { + if (!value || typeof value !== 'object') return false; + + const candidate = value as RawErrorEnvelope; + return ( + candidate.kind !== undefined || + candidate.code !== undefined || + candidate.retryable !== undefined || + candidate.next_action !== undefined || + candidate.retry_action !== undefined || + candidate.message !== undefined + ); +} + +// ========================================== +// 3. Feeds API Requests +// ========================================== + +interface RawFeedRecord { + id?: unknown; + name?: unknown; + url?: unknown; + feed_token?: unknown; + public_url?: unknown; + json_public_url?: unknown; + created_at?: unknown; + updated_at?: unknown; +} + +interface RawFeedPayload { + feed?: RawFeedRecord; +} + +interface RawFeedApiResponse extends RawApiResponse { + data?: RawFeedPayload; +} + +export async function requestFeedCreation(url: string, token: string): Promise { + const response = await fetch(resolveApiUrl('feeds'), { + method: 'POST', + headers: buildCreateHeaders(token), + body: JSON.stringify({ url }), + }); + + const payload = await readJsonResponse(response); + + if (!response.ok) { + throw normalizeFeedCreationErrorFromResponse(response.status, payload?.error, payload); + } + + const feed = normalizeFeedRecord(payload?.data?.feed); + if (!feed) { + throw buildStructuredError( + 'server', + 'INVALID_RESPONSE', + true, + 'retry', + 'primary', + 'Unable to start feed generation.', + response.status + ); + } + + return feed; +} + +export function normalizeFeedRecord(raw?: RawFeedRecord): FeedRecord | undefined { + if (!raw) return undefined; + + const feedToken = normalizeString(raw.feed_token); + const publicUrl = normalizeString(raw.public_url); + const jsonPublicUrl = normalizeString(raw.json_public_url); + const url = normalizeString(raw.url); + + if (!feedToken || !publicUrl || !jsonPublicUrl || !url) return undefined; + + return { + id: normalizeString(raw.id) || feedToken, + name: normalizeString(raw.name) || url, + url, + feed_token: feedToken, + public_url: publicUrl, + json_public_url: jsonPublicUrl, + created_at: normalizeString(raw.created_at) || new Date().toISOString(), + updated_at: normalizeString(raw.updated_at) || new Date().toISOString(), + }; +} + +export function resolveApiUrl(path: string): string { + return `/api/v1/${path.replace(/^\/+/, '')}`; +} + +export function buildCreateHeaders(token: string): HeadersInit { + const normalizedToken = token.trim(); + const headers: Record = { + Accept: 'application/json', + 'Content-Type': 'application/json', + }; + + if (normalizedToken) { + headers.Authorization = `Bearer ${normalizedToken}`; + } + + return headers; +} + +// ========================================== +// 4. Preview Hydration & Retries +// ========================================== + +export const PREVIEW_RETRY_DELAYS_MS = [260, 620, 1180, 1800] as const; +export const PREVIEW_UNAVAILABLE_MESSAGE = 'Preview unavailable right now.'; +export const PREVIEW_DEGRADED_MESSAGE = 'Preview content is partially degraded right now.'; + +export interface PreviewLoadResult { + items: FeedPreviewItem[]; + warnings: FeedPreviewWarning[]; + status: Extract; +} + +interface JsonFeedResponse { + items?: unknown[]; +} + +export function buildCreatedFeedResult(feed: FeedRecord): CreatedFeedResult { + return { + feed, + preview: { + status: 'created', + items: [], + isLoading: false, + }, + warnings: [], + }; +} + +export function buildPreviewLoadingResult(feed: FeedRecord): CreatedFeedResult { + return { + feed, + preview: { + status: 'preview_loading', + items: [], + isLoading: true, + }, + warnings: [], + }; +} + +export async function loadPreviewItemsWithRetry( + previewUrl: string, + signal?: AbortSignal +): Promise { + const delays = [0, ...PREVIEW_RETRY_DELAYS_MS]; + let latestRetryableFailure: PreviewLoadResult | undefined; + + for (const [index, delayMs] of delays.entries()) { + if (delayMs > 0) await wait(delayMs, signal); + + const result = await loadPreviewItems(previewUrl, signal); + if (result.status === 'preview_ready') return result; + if (result.warnings.every((warning) => !warning.retryable)) return result; + + latestRetryableFailure = result; + if (index === delays.length - 1) return result; + } + + return ( + latestRetryableFailure ?? { + items: [], + warnings: [buildPreviewWarning('PREVIEW_FAILED', PREVIEW_UNAVAILABLE_MESSAGE, true, 'retry')], + status: 'preview_failed', + } + ); +} + +export async function loadPreviewItems(previewUrl: string, signal?: AbortSignal): Promise { + let response: Response; + + try { + response = await fetch(resolveFetchUrl(previewUrl), { + headers: { Accept: 'application/feed+json' }, + signal, + }); + } catch (error) { + if (isAbortError(error)) throw error; + + return { + items: [], + warnings: [buildPreviewWarning('PREVIEW_NETWORK_ERROR', PREVIEW_UNAVAILABLE_MESSAGE, true, 'retry')], + status: 'preview_failed', + }; + } + + if (!response.ok) { + return { + items: [], + warnings: [ + buildPreviewWarning( + `PREVIEW_HTTP_${response.status}`, + isTransientHttpStatus(response.status) ? PREVIEW_DEGRADED_MESSAGE : PREVIEW_UNAVAILABLE_MESSAGE, + isTransientHttpStatus(response.status), + isTransientHttpStatus(response.status) ? 'retry' : 'wait' + ), + ], + status: 'preview_failed', + }; + } + + try { + const payload = (await response.json()) as JsonFeedResponse; + return { + items: normalizePreviewItems(payload.items), + warnings: [], + status: 'preview_ready', + }; + } catch { + return { + items: [], + warnings: [buildPreviewWarning('PREVIEW_INVALID_JSON', PREVIEW_UNAVAILABLE_MESSAGE, false, 'wait')], + status: 'preview_failed', + }; + } +} + +export function buildPreviewWarning( + code: string, + message: string, + // eslint-disable-next-line unicorn/consistent-boolean-name + retryable: boolean, + nextAction: FeedNextAction +): FeedPreviewWarning { + return { code, message, retryable, nextAction }; +} + +export function normalizePreviewItems(items: unknown[] | undefined): FeedPreviewItem[] { + if (!Array.isArray(items)) return []; + + return items + .map((item) => normalizePreviewItem(item)) + .filter((item): item is FeedPreviewItem => item !== undefined) + .slice(0, 5); +} + +function normalizePreviewItem(value: unknown): FeedPreviewItem | undefined { + if (!value || typeof value !== 'object') return undefined; + + const candidate = value as { + title?: unknown; + excerpt?: unknown; + description?: unknown; + content_text?: unknown; + contentText?: unknown; + published_label?: unknown; + publishedLabel?: unknown; + date_published?: unknown; + datePublished?: unknown; + date_modified?: unknown; + dateModified?: unknown; + url?: unknown; + }; + + const title = normalizeString(candidate.title); + if (!title) return undefined; + + const url = normalizeString(candidate.url); + + return { + title, + excerpt: + normalizeString( + candidate.excerpt ?? candidate.description ?? candidate.content_text ?? candidate.contentText + ) || '', + publishedLabel: + normalizeString( + candidate.published_label ?? + candidate.publishedLabel ?? + candidate.date_published ?? + candidate.datePublished ?? + candidate.date_modified ?? + candidate.dateModified + ) || '', + ...(url && { url }), + }; +} diff --git a/frontend/src/feeds/previewHydration.ts b/frontend/src/feeds/previewHydration.ts deleted file mode 100644 index 76517bec..00000000 --- a/frontend/src/feeds/previewHydration.ts +++ /dev/null @@ -1,184 +0,0 @@ -import type { - CreatedFeedResult, - FeedPreviewItem, - FeedPreviewStatus, - FeedPreviewWarning, - FeedNextAction, - FeedRecord, -} from '../api/contracts'; -import { isAbortError, isTransientHttpStatus, normalizeString, resolveFetchUrl, wait } from './shared'; - -export const PREVIEW_RETRY_DELAYS_MS = [260, 620, 1180, 1800] as const; -export const PREVIEW_UNAVAILABLE_MESSAGE = 'Preview unavailable right now.'; -export const PREVIEW_DEGRADED_MESSAGE = 'Preview content is partially degraded right now.'; - -export interface PreviewLoadResult { - items: FeedPreviewItem[]; - warnings: FeedPreviewWarning[]; - status: Extract; -} - -interface JsonFeedResponse { - items?: unknown[]; -} - -export function buildCreatedFeedResult(feed: FeedRecord): CreatedFeedResult { - return { - feed, - preview: { - status: 'created', - items: [], - isLoading: false, - }, - warnings: [], - }; -} - -export function buildPreviewLoadingResult(feed: FeedRecord): CreatedFeedResult { - return { - feed, - preview: { - status: 'preview_loading', - items: [], - isLoading: true, - }, - warnings: [], - }; -} - -export async function loadPreviewItemsWithRetry( - previewUrl: string, - signal?: AbortSignal -): Promise { - const delays = [0, ...PREVIEW_RETRY_DELAYS_MS]; - let latestRetryableFailure: PreviewLoadResult | undefined; - - for (const [index, delayMs] of delays.entries()) { - if (delayMs > 0) await wait(delayMs, signal); - - const result = await loadPreviewItems(previewUrl, signal); - if (result.status === 'preview_ready') return result; - if (result.warnings.every((warning) => !warning.retryable)) return result; - - latestRetryableFailure = result; - if (index === delays.length - 1) return result; - } - - return ( - latestRetryableFailure ?? { - items: [], - warnings: [buildPreviewWarning('PREVIEW_FAILED', PREVIEW_UNAVAILABLE_MESSAGE, true, 'retry')], - status: 'preview_failed', - } - ); -} - -export async function loadPreviewItems(previewUrl: string, signal?: AbortSignal): Promise { - let response: Response; - - try { - response = await fetch(resolveFetchUrl(previewUrl), { - headers: { Accept: 'application/feed+json' }, - signal, - }); - } catch (error) { - if (isAbortError(error)) throw error; - - return { - items: [], - warnings: [buildPreviewWarning('PREVIEW_NETWORK_ERROR', PREVIEW_UNAVAILABLE_MESSAGE, true, 'retry')], - status: 'preview_failed', - }; - } - - if (!response.ok) { - return { - items: [], - warnings: [ - buildPreviewWarning( - `PREVIEW_HTTP_${response.status}`, - isTransientHttpStatus(response.status) ? PREVIEW_DEGRADED_MESSAGE : PREVIEW_UNAVAILABLE_MESSAGE, - isTransientHttpStatus(response.status), - isTransientHttpStatus(response.status) ? 'retry' : 'wait' - ), - ], - status: 'preview_failed', - }; - } - - try { - const payload = (await response.json()) as JsonFeedResponse; - return { - items: normalizePreviewItems(payload.items), - warnings: [], - status: 'preview_ready', - }; - } catch { - return { - items: [], - warnings: [buildPreviewWarning('PREVIEW_INVALID_JSON', PREVIEW_UNAVAILABLE_MESSAGE, false, 'wait')], - status: 'preview_failed', - }; - } -} - -export function buildPreviewWarning( - code: string, - message: string, - // eslint-disable-next-line unicorn/consistent-boolean-name - retryable: boolean, - nextAction: FeedNextAction -): FeedPreviewWarning { - return { code, message, retryable, nextAction }; -} - -export function normalizePreviewItems(items: unknown[] | undefined): FeedPreviewItem[] { - if (!Array.isArray(items)) return []; - - return items - .map((item) => normalizePreviewItem(item)) - .filter((item): item is FeedPreviewItem => item !== undefined) - .slice(0, 5); -} - -function normalizePreviewItem(value: unknown): FeedPreviewItem | undefined { - if (!value || typeof value !== 'object') return undefined; - - const candidate = value as { - title?: unknown; - excerpt?: unknown; - description?: unknown; - content_text?: unknown; - contentText?: unknown; - published_label?: unknown; - publishedLabel?: unknown; - date_published?: unknown; - datePublished?: unknown; - date_modified?: unknown; - dateModified?: unknown; - url?: unknown; - }; - - const title = normalizeString(candidate.title); - if (!title) return undefined; - - const url = normalizeString(candidate.url); - - return { - title, - excerpt: - normalizeString( - candidate.excerpt ?? candidate.description ?? candidate.content_text ?? candidate.contentText - ) || '', - publishedLabel: - normalizeString( - candidate.published_label ?? - candidate.publishedLabel ?? - candidate.date_published ?? - candidate.datePublished ?? - candidate.date_modified ?? - candidate.dateModified - ) || '', - ...(url && { url }), - }; -} diff --git a/frontend/src/feeds/shared.ts b/frontend/src/feeds/shared.ts deleted file mode 100644 index b7ff82dc..00000000 --- a/frontend/src/feeds/shared.ts +++ /dev/null @@ -1,62 +0,0 @@ -export function normalizeString(value: unknown): string | undefined { - return typeof value === 'string' && value.trim() ? value.trim() : undefined; -} - -// eslint-disable-next-line unicorn/consistent-boolean-name -export function normalizeBoolean(value: unknown, fallback: boolean): boolean { - return typeof value === 'boolean' ? value : fallback; -} - -export function isTransientHttpStatus(status: number): boolean { - return [408, 409, 425, 429, 500, 502, 503, 504].includes(status); -} - -export function isAbortError(error: unknown): boolean { - return ( - (error instanceof DOMException && error.name === 'AbortError') || - (error instanceof Error && error.name === 'AbortError') - ); -} - -export async function wait(delayMs: number, signal?: AbortSignal): Promise { - if (delayMs <= 0) return; - - await new Promise((resolve, reject) => { - const timeoutHandle = setTimeout(() => { - signal?.removeEventListener('abort', onAbort); - resolve(); - }, delayMs); - - const onAbort = () => { - clearTimeout(timeoutHandle); - reject(new DOMException('Aborted', 'AbortError')); - }; - - if (signal) { - if (signal.aborted) { - clearTimeout(timeoutHandle); - reject(new DOMException('Aborted', 'AbortError')); - return; - } - - signal.addEventListener('abort', onAbort, { once: true }); - } - }); -} - -export function resolveFetchUrl(url: string): string { - if (/^https?:\/\//i.test(url)) return url; - const origin = globalThis.location?.origin ?? 'http://localhost'; - return new URL(url, origin).href; -} - -export async function readJsonResponse(response: Response): Promise { - const bodyText = await response.text(); - if (!bodyText.trim()) return undefined; - - try { - return JSON.parse(bodyText) as T; - } catch { - return undefined; - } -} diff --git a/frontend/src/hooks/useAppPresenter.ts b/frontend/src/hooks/useAppPresenter.ts new file mode 100644 index 00000000..2c6eb9ab --- /dev/null +++ b/frontend/src/hooks/useAppPresenter.ts @@ -0,0 +1,255 @@ +import { useEffect, useRef, useState } from 'preact/hooks'; +import { useAccessToken } from './useAccessToken'; +import { useApiMetadata } from './useApiMetadata'; +import { useFeedConversion } from './useFeedConversion'; +import { useAppRoute } from '../routes/appRoute'; +import { clearFeedDraftState, loadFeedDraftState, saveFeedDraftState } from '../utils/feedWorkflowStorage'; +import { normalizeUserUrl } from '../utils/url'; +import { deriveAppViewModel } from '../appViewModel'; +import type { FeedCreationError } from '../api/contracts'; + +const EMPTY_FEED_ERRORS = { url: '', form: '' }; +const DEFAULT_FEED_CREATION = { enabled: true, access_token_required: true }; + +/** + * Custom hook that consolidates app coordination, route synchronization, + * auto-submit effects, and side-effects. + */ +export function useAppPresenter() { + const { route, navigate } = useAppRoute(); + const { + token, + hasToken, + saveToken, + clearToken, + isLoading: tokenLoading, + error: tokenStateError, + } = useAccessToken(); + const { metadata, isLoading: metadataLoading, error: metadataError } = useApiMetadata(); + const { + isConverting, + result, + error: conversionError, + convertFeed, + clearError, + clearResult, + retryPreviewFetch, + } = useFeedConversion(); + + const [feedFormData, setFeedFormData] = useState(() => loadFeedDraftState() ?? { url: '' }); + const [feedFieldErrors, setFeedFieldErrors] = useState(EMPTY_FEED_ERRORS); + const [tokenDraft, setTokenDraft] = useState(''); + const [tokenError, setTokenError] = useState(''); + const [bookmarkletNotice, setBookmarkletNotice] = useState(''); + const [focusCreateComposerKey, setFocusCreateComposerKey] = useState(0); + + const autoSubmitUrlReference = useRef(route.prefillUrl); + const hasAutoSubmittedReference = useRef(false); + + const isTokenRoute = route.kind === 'token'; + const activeResult = + route.kind === 'result' && result?.feed.feed_token === route.feedToken ? result : undefined; + + let visibleRouteKind: 'create' | 'token' | 'result' = 'create'; + if (activeResult) { + visibleRouteKind = 'result'; + } else if (isTokenRoute) { + visibleRouteKind = 'token'; + } + + const viewModel = deriveAppViewModel({ + conversionError, + feedFieldErrors, + isConverting, + routeKind: visibleRouteKind, + tokenError, + tokenStateError, + metadataError, + result: activeResult, + }); + + // Prefill URL effect + useEffect(() => { + if (!route.prefillUrl) return; + autoSubmitUrlReference.current = route.prefillUrl; + if (feedFormData.url) return; + + setFeedFormData((previous) => ({ ...previous, url: route.prefillUrl ?? previous.url })); + }, [feedFormData.url, route.prefillUrl]); + + const feedCreation = metadata?.instance.feed_creation ?? DEFAULT_FEED_CREATION; + const featuredFeeds = metadata?.instance.featured_feeds ?? []; + const submitDisabled = isConverting || !feedCreation.enabled || viewModel.kind === 'token_prompt'; + + const onFeedFieldChange = (key: 'url', value: string) => { + setFeedFormData((previous) => { + const next = { ...previous, [key]: value }; + if (next.url.trim()) { + saveFeedDraftState(next); + } else { + clearFeedDraftState(); + } + return next; + }); + setFeedFieldErrors((previous) => ({ ...previous, url: '', form: '' })); + clearError(); + }; + + const attemptFeedCreation = async (accessToken: string) => { + const normalizedUrl = normalizeUserUrl(feedFormData.url); + + if (!normalizedUrl) { + setFeedFieldErrors({ ...EMPTY_FEED_ERRORS, url: 'Source URL is required.' }); + return false; + } + + if (!feedCreation.enabled) { + setFeedFieldErrors({ + ...EMPTY_FEED_ERRORS, + form: 'Feed creation is disabled on this instance.', + }); + return false; + } + + if (feedCreation.access_token_required && !accessToken) { + setFeedFormData((previous) => ({ ...previous, url: normalizedUrl })); + clearError(); + setTokenError(''); + if (route.kind !== 'token') navigate({ kind: 'token', prefillUrl: normalizedUrl }); + return false; + } + + try { + setFeedFormData((previous) => ({ ...previous, url: normalizedUrl })); + const createdResult = await convertFeed(normalizedUrl, accessToken); + clearFeedDraftState(); + navigate({ kind: 'result', feedToken: createdResult.feed.feed_token }); + setTokenError(''); + return true; + } catch (submitError) { + const failure = submitError as FeedCreationError; + + if (failure.kind === 'auth' || failure.nextAction === 'enter_token') { + clearToken(); + clearError(); + setTokenDraft(''); + if (route.kind !== 'token') navigate({ kind: 'token', prefillUrl: normalizedUrl }); + setTokenError('Access token was rejected. Paste a valid token to continue.'); + setFeedFieldErrors(EMPTY_FEED_ERRORS); + return false; + } + + if (failure.nextAction === 'correct_input') { + setFeedFieldErrors({ ...EMPTY_FEED_ERRORS, form: failure.message }); + return false; + } + + setFeedFieldErrors({ ...EMPTY_FEED_ERRORS, form: failure.message }); + return false; + } + }; + + const onFeedSubmit = async (event: Event) => { + event.preventDefault(); + setFeedFieldErrors(EMPTY_FEED_ERRORS); + await attemptFeedCreation(token ?? ''); + }; + + const onSaveToken = async () => { + try { + const normalizedToken = tokenDraft.trim(); + await saveToken(normalizedToken); + setTokenError(''); + const created = await attemptFeedCreation(normalizedToken); + if (created) setTokenDraft(''); + } catch (error) { + setTokenError(error instanceof Error ? error.message : 'Unable to save access token.'); + } + }; + + const onCreateAnother = () => { + clearResult(); + setFocusCreateComposerKey((current) => current + 1); + navigate({ kind: 'create', prefillUrl: feedFormData.url || undefined }); + }; + + const onRetryCreate = () => { + setFeedFieldErrors(EMPTY_FEED_ERRORS); + clearError(); + void attemptFeedCreation(token ?? ''); + }; + + // Auto-submit effect + useEffect(() => { + const autoSubmitUrl = autoSubmitUrlReference.current; + if (!autoSubmitUrl || hasAutoSubmittedReference.current) return; + if (metadataLoading || tokenLoading) return; + if (feedFormData.url !== autoSubmitUrl) return; + + if (feedCreation.access_token_required && !token) { + hasAutoSubmittedReference.current = true; + setFeedFormData((previous) => ({ ...previous, url: normalizeUserUrl(autoSubmitUrl) })); + setTokenError(''); + if (route.kind !== 'token') { + navigate({ kind: 'token', prefillUrl: normalizeUserUrl(autoSubmitUrl) }); + } + return; + } + + hasAutoSubmittedReference.current = true; + setFeedFieldErrors(EMPTY_FEED_ERRORS); + void attemptFeedCreation(token ?? ''); + }, [ + feedCreation.access_token_required, + feedFormData.url, + metadataLoading, + navigate, + route.kind, + token, + tokenLoading, + ]); + + return { + route, + viewModel, + feedFormData, + feedFieldErrors, + tokenDraft, + tokenError, + bookmarkletNotice, + focusCreateComposerKey, + metadata, + hasToken, + submitDisabled, + feedCreationEnabled: feedCreation.enabled, + featuredFeeds, + metadataLoading, + tokenLoading, + metadataError, + tokenStateError, + onFeedSubmit, + onFeedFieldChange, + onTokenDraftChange: (value: string) => { + setTokenDraft(value); + setTokenError(''); + clearError(); + }, + onSaveToken, + onCancelTokenPrompt: () => { + setTokenError(''); + clearError(); + navigate({ kind: 'create', prefillUrl: feedFormData.url || undefined }); + }, + onRetryCreate, + onCreateAnother, + onRetryPreview: retryPreviewFetch, + onClearToken: () => { + clearToken(); + clearError(); + navigate({ kind: 'create', prefillUrl: feedFormData.url || undefined }); + }, + onShowBookmarkletHelp: () => setBookmarkletNotice('show'), + setBookmarkletNotice, + navigate, + }; +} diff --git a/frontend/src/hooks/useFeedConversion.ts b/frontend/src/hooks/useFeedConversion.ts index e51fcd3f..fa3ff084 100644 --- a/frontend/src/hooks/useFeedConversion.ts +++ b/frontend/src/hooks/useFeedConversion.ts @@ -1,15 +1,16 @@ import { useEffect, useRef, useState } from 'preact/hooks'; import type { CreatedFeedResult, FeedCreationError } from '../api/contracts'; -import { buildLocalError, normalizeFeedCreationError } from '../feeds/feedCreationError'; -import { requestFeedCreation } from '../feeds/feedsApi'; import { + buildLocalError, + normalizeFeedCreationError, + requestFeedCreation, buildCreatedFeedResult, buildPreviewLoadingResult, buildPreviewWarning, loadPreviewItemsWithRetry, PREVIEW_UNAVAILABLE_MESSAGE, -} from '../feeds/previewHydration'; -import { isAbortError } from '../feeds/shared'; + isAbortError, +} from '../feeds/feedsService'; import { isNormalizedHttpUrl, normalizeUserUrl } from '../utils/url'; interface ConversionState { diff --git a/spec/html2rss/web/api/v1_spec.rb b/spec/html2rss/web/api/v1_spec.rb index 2c36a032..230620fb 100644 --- a/spec/html2rss/web/api/v1_spec.rb +++ b/spec/html2rss/web/api/v1_spec.rb @@ -112,8 +112,8 @@ def json_feed_response_for(token) 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":[]}') + allow(Html2rss::Web::Feeds::Renderer).to receive(:call).with(feed_result, format: :json_feed) + .and_return('{"version":"https://jsonfeed.org/version/1.1","items":[]}') end def json_feed_headers_tuple @@ -375,7 +375,8 @@ def expected_featured_feeds token = Html2rss::Web::Auth.generate_feed_token('admin', feed_url, strategy: 'faraday') 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::Renderer).to receive(:call).with(feed_result, + format: :rss).and_return('') get "/api/v1/feeds/#{token}.xml" @@ -395,7 +396,8 @@ def expected_featured_feeds token = Html2rss::Web::Auth.generate_feed_token('admin', feed_url, strategy: 'faraday') 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::Renderer).to receive(:call).with(feed_result, + format: :rss).and_return('') get "/api/v1/feeds/#{token}", {}, { 'HTTP_ACCEPT' => 'application/xml;q=1.0, application/feed+json;q=0.2' } @@ -407,7 +409,8 @@ def expected_featured_feeds token = Html2rss::Web::Auth.generate_feed_token('admin', feed_url, strategy: 'faraday') 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::Renderer).to receive(:call).with(feed_result, + format: :rss).and_return('') get "/api/v1/feeds/#{token}", { strategy: 'bad' }, { 'HTTP_ACCEPT' => 'application/xml' } @@ -492,7 +495,8 @@ def expected_featured_feeds it 'returns 422 for empty extraction feeds in xml representation', :aggregate_failures, openapi: false do token = Html2rss::Web::Auth.generate_feed_token('admin', "#{feed_url}/empty-xml", strategy: 'faraday') allow(Html2rss::Web::Feeds::Service).to receive(:call).and_return(extraction_empty_result) - allow(Html2rss::Web::Feeds::RssRenderer).to receive(:call).and_return('') + allow(Html2rss::Web::Feeds::Renderer).to receive(:call).with(extraction_empty_result, + format: :rss).and_return('') get "/api/v1/feeds/#{token}.xml" @@ -504,7 +508,8 @@ def expected_featured_feeds it 'returns 422 for empty extraction feeds in json feed representation', :aggregate_failures, openapi: false do token = Html2rss::Web::Auth.generate_feed_token('admin', "#{feed_url}/empty-json", strategy: 'faraday') allow(Html2rss::Web::Feeds::Service).to receive(:call).and_return(extraction_empty_result) - allow(Html2rss::Web::Feeds::JsonRenderer).to receive(:call) + allow(Html2rss::Web::Feeds::Renderer).to receive(:call) + .with(extraction_empty_result, format: :json_feed) .and_return('{"version":"https://jsonfeed.org/version/1.1","title":"Content Extraction Issue","items":[]}') get "/api/v1/feeds/#{token}.json" @@ -525,7 +530,8 @@ def expected_featured_feeds token = Html2rss::Web::Auth.generate_feed_token('admin', "#{feed_url}/rate-limited-429", strategy: 'faraday') 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::Renderer).to receive(:call).with(feed_result, + format: :rss).and_return('') get "/api/v1/feeds/#{token}.xml", {}, { 'REMOTE_ADDR' => '192.168.99.1' } expect(last_response.status).to eq(200) diff --git a/spec/html2rss/web/app_integration_spec.rb b/spec/html2rss/web/app_integration_spec.rb index 9b6c4914..fbe46bb9 100644 --- a/spec/html2rss/web/app_integration_spec.rb +++ b/spec/html2rss/web/app_integration_spec.rb @@ -62,9 +62,10 @@ stub_const('Html2rss::Feed', Class.new { attr_reader :channel }) allow(Html2rss::Web::AutoSource).to receive(:enabled?).and_return(true) 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":[]}') + allow(Html2rss::Web::Feeds::Renderer).to receive(:call).with(feed_result, + format: :rss).and_return('') + allow(Html2rss::Web::Feeds::Renderer).to receive(:call).with(feed_result, format: :json_feed) + .and_return('{"version":"https://jsonfeed.org/version/1.1","items":[]}') end describe 'GET /create, /token, /result/:token' do # rubocop:disable RSpec/MultipleMemoizedHelpers @@ -262,7 +263,7 @@ def stub_empty_feed_warning_result error_kind: nil ) ) - allow(Html2rss::Web::Feeds::JsonRenderer).to receive(:call) + allow(Html2rss::Web::Feeds::Renderer).to receive(:call) .and_return('{"version":"https://jsonfeed.org/version/1.1","title":"Content Extraction Issue","items":[]}') end # rubocop:enable Metrics/AbcSize, Metrics/MethodLength diff --git a/spec/html2rss/web/app_spec.rb b/spec/html2rss/web/app_spec.rb index cd44b96c..2a8269a8 100644 --- a/spec/html2rss/web/app_spec.rb +++ b/spec/html2rss/web/app_spec.rb @@ -43,8 +43,8 @@ def static_feed_result(ttl:) 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) + allow(Html2rss::Web::Feeds::Renderer).to receive(:call).with(result, format: :rss).and_return(rss_body) + allow(Html2rss::Web::Feeds::Renderer).to receive(:call).with(result, format: :json_feed).and_return(json_body) end def static_service_error_result @@ -65,7 +65,7 @@ def stub_static_service_error(feed_name) .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('') + allow(Html2rss::Web::Feeds::Renderer).to receive(:call_error).and_return('') end def service_error_response_tuple(path) @@ -183,7 +183,7 @@ def app = described_class end it 'renders XML not found when static feed config is missing' do - allow(Html2rss::Web::XmlBuilder).to receive(:build_error_feed).and_return('') + allow(Html2rss::Web::Feeds::Renderer).to receive(:call_error).and_return('') get '/missing-feed' diff --git a/spec/html2rss/web/error_responder_spec.rb b/spec/html2rss/web/error_responder_spec.rb index 33a89547..bd0c3691 100644 --- a/spec/html2rss/web/error_responder_spec.rb +++ b/spec/html2rss/web/error_responder_spec.rb @@ -48,7 +48,7 @@ def extraction_empty_api_error_response end def legacy_error_response - allow(Html2rss::Web::XmlBuilder).to receive(:build_error_feed).and_return('') + allow(Html2rss::Web::Feeds::Renderer).to receive(:call_error).and_return('') response, body = respond_with( error: Html2rss::Web::InternalServerError.new('oops'), path: '/legacy' diff --git a/spec/html2rss/web/feed_accept_header_spec.rb b/spec/html2rss/web/feed_accept_header_spec.rb deleted file mode 100644 index 48bb76fb..00000000 --- a/spec/html2rss/web/feed_accept_header_spec.rb +++ /dev/null @@ -1,40 +0,0 @@ -# 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_notice_text_spec.rb b/spec/html2rss/web/feed_notice_text_spec.rb deleted file mode 100644 index 37649748..00000000 --- a/spec/html2rss/web/feed_notice_text_spec.rb +++ /dev/null @@ -1,20 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_relative '../../../app' - -RSpec.describe Html2rss::Web::FeedNoticeText do - describe '.empty_feed_item' do - subject(:message) { described_class.empty_feed_item(url: 'https://example.com/articles') } - - it 'includes actionable product guidance' do - expect(message).to include('What you can do:') - expect(message).to include('Try again in a few moments') - end - - it 'does not mention hidden strategy controls' do - expect(message).not_to include('browserless strategy') - expect(message).not_to include('Try another strategy') - end - end -end diff --git a/spec/html2rss/web/feed_response_format_spec.rb b/spec/html2rss/web/feed_response_format_spec.rb new file mode 100644 index 00000000..4e3f9048 --- /dev/null +++ b/spec/html2rss/web/feed_response_format_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../../../app' + +RSpec.describe Html2rss::Web::FeedResponseFormat do + describe '.from_path' do + it 'resolves format from path suffix', :aggregate_failures do + expect(described_class.from_path('/feed.json')).to eq(:json_feed) + expect(described_class.from_path('/feed.rss')).to eq(:rss) + expect(described_class.from_path('/feed.xml')).to eq(:rss) + expect(described_class.from_path('/feed')).to be_nil + end + end + + describe '.strip_known_extension' do + it 'removes known extensions', :aggregate_failures do + expect(described_class.strip_known_extension('/feed.json')).to eq('/feed') + expect(described_class.strip_known_extension('/feed.rss')).to eq('/feed') + expect(described_class.strip_known_extension('/feed')).to eq('/feed') + end + end + + describe '.content_type' do + it 'returns proper HTTP header string', :aggregate_failures do + expect(described_class.content_type(:json_feed)).to eq('application/feed+json') + expect(described_class.content_type(:rss)).to eq('application/xml') + end + end + + describe '.from_accept' do + subject(:from_accept) { described_class.from_accept(accept_header) } + + 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(from_accept).to eq(: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(from_accept).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(from_accept).to eq(:json_feed) + end + end + end +end diff --git a/spec/html2rss/web/feeds/json_renderer_spec.rb b/spec/html2rss/web/feeds/json_renderer_spec.rb deleted file mode 100644 index 15aa5b32..00000000 --- a/spec/html2rss/web/feeds/json_renderer_spec.rb +++ /dev/null @@ -1,45 +0,0 @@ -# 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::Contracts::RenderPayload.new( - feed: Object.new, - site_title: 'https://example.com/articles', - url: 'https://example.com/articles', - strategy: 'faraday' - ) - end - let(:empty_result) do - Html2rss::Web::Feeds::Contracts::RenderResult.new( - status: :empty, - payload: payload, - message: nil, - ttl_seconds: 600, - cache_key: 'feed_result:test', - error_message: nil, - error_kind: 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: 'faraday', - site_title: 'https://example.com/articles' - } - end -end diff --git a/spec/html2rss/web/feeds/renderer_spec.rb b/spec/html2rss/web/feeds/renderer_spec.rb new file mode 100644 index 00000000..89b24cda --- /dev/null +++ b/spec/html2rss/web/feeds/renderer_spec.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../../../../app' + +RSpec.describe Html2rss::Web::Feeds::Renderer do + let(:mock_feed) do + instance_double( + RSS::Rss, + to_s: '', + channel: instance_double(RSS::Rss::Channel, title: 'Test Title', link: 'https://example.com', + description: 'Test Desc'), + items: [ + instance_double( + RSS::Rss::Channel::Item, + guid: instance_double(RSS::Rss::Channel::Item::Guid, content: '1'), + link: 'https://example.com/1', + title: 'Item 1', + description: 'Item Description', + pubDate: Time.now.utc + ) + ] + ) + end + + let(:ok_payload) do + Html2rss::Web::Feeds::Contracts::RenderPayload.new( + feed: mock_feed, + site_title: 'Test Title', + url: 'https://example.com', + strategy: 'faraday' + ) + end + + let(:empty_payload) do + Html2rss::Web::Feeds::Contracts::RenderPayload.new( + feed: nil, + site_title: 'Test Title', + url: 'https://example.com', + strategy: 'faraday' + ) + end + + describe '.call' do + context 'with ok status' do + let(:result) do + Html2rss::Web::Feeds::Contracts::RenderResult.new( + status: :ok, + payload: ok_payload, + message: nil, + ttl_seconds: 300, + cache_key: 'key', + error_message: nil, + error_kind: nil + ) + end + + it 'renders RSS XML directly' do + expect(described_class.call(result, format: :rss)).to eq('') + end + + it 'renders JSON Feed format', :aggregate_failures do + json = JSON.parse(described_class.call(result, format: :json_feed)) + expect(json['version']).to eq('https://jsonfeed.org/version/1.1') + expect(json['title']).to eq('Test Title') + expect(json['items'].first['id']).to eq('1') + end + end + + context 'with empty status' do + let(:result) do + Html2rss::Web::Feeds::Contracts::RenderResult.new( + status: :empty, + payload: empty_payload, + message: nil, + ttl_seconds: 300, + cache_key: 'key', + error_message: 'empty page', + error_kind: :extraction_empty + ) + end + + it 'renders empty warnings in RSS' do + xml = described_class.call(result, format: :rss) + expect(xml).to include('Content Extraction Issue') + expect(xml).to include('Preview unavailable for this source') + end + + it 'renders empty warnings in JSON Feed' do + json = JSON.parse(described_class.call(result, format: :json_feed)) + expect(json['title']).to include('Content Extraction Issue') + expect(json['items'].first['content_text']).to include('What you can do') + end + end + end + + describe '.call_error' do + before do + config_double = instance_double(Html2rss::Config) + allow(Html2rss).to receive(:configuration).and_return(config_double) + allow(config_double).to receive(:stylesheets).and_return([{ href: '/custom.xsl', type: 'text/xsl' }]) + end + + it 'renders error in RSS and applies stylesheets', :aggregate_failures do + xml = described_class.call_error(message: 'Invalid key', format: :rss) + expect(xml).to include('Failed to generate feed: Invalid key') + expect(xml).to include('') + end + + it 'renders error in JSON Feed' do + json = JSON.parse(described_class.call_error(message: 'Invalid key', format: :json_feed)) + expect(json['description']).to include('Failed to generate feed: Invalid key') + end + end +end diff --git a/spec/html2rss/web/feeds/responder_spec.rb b/spec/html2rss/web/feeds/responder_spec.rb index 40052713..10c743ad 100644 --- a/spec/html2rss/web/feeds/responder_spec.rb +++ b/spec/html2rss/web/feeds/responder_spec.rb @@ -40,7 +40,7 @@ before do allow(Html2rss::Web::Feeds::Service).to receive(:call).and_return(result) - allow(Html2rss::Web::Feeds::RssRenderer).to receive(:call).with(result).and_return('') + allow(Html2rss::Web::Feeds::Renderer).to receive(:call).with(result, format: :rss).and_return('') end it 'writes the expected response tuple' do @@ -89,7 +89,8 @@ before do allow(Html2rss::Web::Feeds::Service).to receive(:call).and_return(result) - allow(Html2rss::Web::Feeds::JsonRenderer).to receive(:call).with(result).and_return('{"title":"Error"}') + allow(Html2rss::Web::Feeds::Renderer).to receive(:call).with(result, + format: :json_feed).and_return('{"title":"Error"}') end it 'writes the expected response tuple' do @@ -132,9 +133,9 @@ before do allow(Html2rss::Web::Feeds::Service).to receive(:call).and_return(result) - allow(Html2rss::Web::Feeds::JsonRenderer) + allow(Html2rss::Web::Feeds::Renderer) .to receive(:call) - .with(result) + .with(result, format: :json_feed) .and_return('{"title":"Content Extraction Issue"}') end @@ -167,7 +168,7 @@ before do allow(Html2rss::Web::Feeds::Service).to receive(:call).and_return(result) - allow(Html2rss::Web::Feeds::RssRenderer).to receive(:call).and_raise(StandardError, 'render failed') + allow(Html2rss::Web::Feeds::Renderer).to receive(:call).and_raise(StandardError, 'render failed') end it 'emits only the failure event' do diff --git a/spec/html2rss/web/feeds/rss_renderer_spec.rb b/spec/html2rss/web/feeds/rss_renderer_spec.rb deleted file mode 100644 index 108b7d40..00000000 --- a/spec/html2rss/web/feeds/rss_renderer_spec.rb +++ /dev/null @@ -1,45 +0,0 @@ -# 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::Contracts::RenderPayload.new( - feed: Object.new, - site_title: 'https://example.com/articles', - url: 'https://example.com/articles', - strategy: 'faraday' - ) - end - let(:empty_result) do - Html2rss::Web::Feeds::Contracts::RenderResult.new( - status: :empty, - payload: payload, - message: nil, - ttl_seconds: 600, - cache_key: 'feed_result:test', - error_message: nil, - error_kind: 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: 'faraday', - site_title: 'https://example.com/articles' - } - end -end diff --git a/spec/html2rss/web/json_feed_builder_spec.rb b/spec/html2rss/web/json_feed_builder_spec.rb deleted file mode 100644 index 5aede1fe..00000000 --- a/spec/html2rss/web/json_feed_builder_spec.rb +++ /dev/null @@ -1,34 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require_relative '../../../app' - -RSpec.describe Html2rss::Web::JsonFeedBuilder do - describe '.build_empty_feed_warning' do - subject(:payload) do - JSON.parse( - described_class.build_empty_feed_warning( - url: 'https://example.com/articles', - strategy: 'faraday', - site_title: 'Example Site' - ) - ) - end - - it 'uses updated channel description copy' do - expect(payload.fetch('description')).to include('We could not extract entries') - expect(payload.fetch('description')).not_to include('different parser') - end - - it 'uses updated item title and content text' do - first_item = payload.fetch('items').first - expect(first_item.fetch('title')).to eq('Preview unavailable for this source') - expect(first_item.fetch('content_text')).to include('What you can do:') - end - - it 'does not mention hidden strategy controls in item text' do - first_item = payload.fetch('items').first - expect(first_item.fetch('content_text')).not_to include('browserless strategy') - end - end -end diff --git a/spec/html2rss/web/xml_builder_spec.rb b/spec/html2rss/web/xml_builder_spec.rb deleted file mode 100644 index ffd4f921..00000000 --- a/spec/html2rss/web/xml_builder_spec.rb +++ /dev/null @@ -1,37 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require 'nokogiri' -require_relative '../../../app' - -RSpec.describe Html2rss::Web::XmlBuilder do - describe '.build_empty_feed_warning' do - subject(:xml_doc) do - xml = described_class.build_empty_feed_warning( - url: 'https://example.com/articles', - strategy: 'faraday', - site_title: 'Example Site' - ) - Nokogiri::XML(xml) - end - - it 'uses updated channel description copy' do - description = xml_doc.at_xpath('//channel/description').text - expect(description).to include('We could not extract entries') - expect(description).not_to include('different parser') - end - - it 'uses updated item title and content text' do - expect(xml_doc.at_xpath('//item/title').text).to eq('Preview unavailable for this source') - expect(xml_doc.at_xpath('//item/description').text).to include('What you can do:') - end - - it 'does not mention hidden strategy controls in item text' do - expect(xml_doc.at_xpath('//item/description').text).not_to include('browserless strategy') - end - - it 'includes the default stylesheet processing instruction' do - expect(xml_doc.to_s).to include('') - end - end -end