From 9cd6d9714f76b3675842623dc587fe3424e7ec4f Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sat, 29 Aug 2026 12:10:21 +0200 Subject: [PATCH 1/4] feat(catalog): last_result and starters on configs v2 Record process-local scrape outcomes on directory-defaults static feeds and join them into typed catalog entries so clients can demote failing rows and consume server-owned meta.starters instead of lockstep starter ID lists. --- AGENTS.md | 6 +- app/web/api/v1/configs.rb | 10 +- app/web/catalog/entry.rb | 55 +++++++ app/web/catalog/merge.rb | 153 +++++++++--------- app/web/catalog/parameter_defaults.rb | 29 ++++ app/web/catalog/starters.rb | 64 ++++++++ app/web/feeds/contracts.rb | 16 +- app/web/feeds/directory_params.rb | 41 +++++ app/web/feeds/last_result.rb | 50 ++++++ app/web/feeds/last_results.rb | 52 ++++++ app/web/feeds/service.rb | 26 +++ app/web/feeds/source_resolver.rb | 54 ++++--- public/openapi.yaml | 19 +++ spec/html2rss/web/api/v1_spec.rb | 9 +- spec/html2rss/web/catalog/merge_spec.rb | 38 +++-- spec/html2rss/web/catalog/starters_spec.rb | 49 ++++++ .../web/feeds/directory_params_spec.rb | 20 +++ .../web/feeds/feed_result_integration_spec.rb | 5 +- spec/html2rss/web/feeds/last_results_spec.rb | 30 ++++ spec/html2rss/web/feeds/service_spec.rb | 41 ++++- .../web/feeds/source_resolver_spec.rb | 11 ++ 21 files changed, 655 insertions(+), 123 deletions(-) create mode 100644 app/web/catalog/entry.rb create mode 100644 app/web/catalog/parameter_defaults.rb create mode 100644 app/web/catalog/starters.rb create mode 100644 app/web/feeds/directory_params.rb create mode 100644 app/web/feeds/last_result.rb create mode 100644 app/web/feeds/last_results.rb create mode 100644 spec/html2rss/web/catalog/starters_spec.rb create mode 100644 spec/html2rss/web/feeds/directory_params_spec.rb create mode 100644 spec/html2rss/web/feeds/last_results_spec.rb diff --git a/AGENTS.md b/AGENTS.md index 4949b15d..59818255 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,8 +65,10 @@ Public feed-directory metadata for embedded and local configs. | Disabled response | `404` with `{ "error": "catalog_disabled" }` | | Embedded entries | `Html2rss::Configs::Catalog.entries` — do not re-walk YAML in the handler | | Local entries | `Catalog::Merge` includes `feeds.yml` feeds only when `directory.title` is set | -| Starter feeds (UI) | Frontend `selectStarterFeeds` for empty Create / creation-disabled; catalog find uses full catalog when enabled | -| Catalog find | `findCatalogEntries` → multi-hit list under create URL; links via `catalogFeedHref` (path + defaults) | +| Starter feeds (UI) | Server `meta.starters` via `Catalog::Starters.pick`; frontend `selectStarterFeeds(entries, starters)` | +| Catalog find | `findCatalogEntries` → multi-hit list under create URL; demotes `empty`/`error`; links via `catalogFeedHref` | +| last_result | Required on every catalog entry (`ok`/`empty`/`error`/`unknown`); process-local `Feeds::LastResults` | +| catalog_version | `2` (clients fail-closed on other versions) | | CORS | Route-scoped on `/api/v1/configs` only (`GET`, `OPTIONS`) | | Root metadata | `GET /api/v1/` exposes `instance.catalog: { enabled, url }` | | Contract SSOT | Request specs under `spec/html2rss/web/api/v1_spec.rb` and generated `public/openapi.yaml` | diff --git a/app/web/api/v1/configs.rb b/app/web/api/v1/configs.rb index 900b9b9b..2b596148 100644 --- a/app/web/api/v1/configs.rb +++ b/app/web/api/v1/configs.rb @@ -7,7 +7,7 @@ module V1 ## # Public config catalog endpoint for feed directory clients. module Configs - CATALOG_VERSION = 1 + CATALOG_VERSION = 2 class << self ## @@ -50,8 +50,12 @@ def emit_failure(error) def success_payload(entries) Response.success( - data: { configs: entries }, - meta: { total: entries.size, catalog_version: CATALOG_VERSION } + data: { configs: entries.map(&:to_h) }, + meta: { + total: entries.size, + catalog_version: CATALOG_VERSION, + starters: Catalog::Starters.pick(entries) + } ) end end diff --git a/app/web/catalog/entry.rb b/app/web/catalog/entry.rb new file mode 100644 index 00000000..5c58e7b3 --- /dev/null +++ b/app/web/catalog/entry.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module Html2rss + module Web + module Catalog + ## + # Domain catalog row for the public configs API (catalog_version 2). + # + # Always carries a {Feeds::LastResult}; wire encoding is {#to_h}. + Entry = Data.define( + :id, + :path, + :source, + :directory, + :channel, + :parameters, + :last_result + ) do + ## + # Builds an entry from an embedded/local hash plus a last-result join. + # + # @param hash [Hash{Symbol => Object}] + # @param last_result [Html2rss::Web::Feeds::LastResult] + # @return [Html2rss::Web::Catalog::Entry] + def self.from_hash(hash, last_result:) + new( + id: hash.fetch(:id), + path: hash.fetch(:path), + source: hash.fetch(:source), + directory: hash.fetch(:directory), + channel: hash.fetch(:channel), + parameters: hash.fetch(:parameters), + last_result: + ) + end + + ## + # Catalog wire shape (required +last_result+). + # + # @return [Hash{Symbol => Object}] + def to_h + { + id:, + path:, + source:, + directory:, + channel:, + parameters:, + last_result: last_result.to_h + } + end + end + end + end +end diff --git a/app/web/catalog/merge.rb b/app/web/catalog/merge.rb index 77405782..26be3af2 100644 --- a/app/web/catalog/merge.rb +++ b/app/web/catalog/merge.rb @@ -8,94 +8,93 @@ module Web # Merges embedded catalog entries with local feed configs for the public catalog API. module Catalog module Merge - # Prefer Faraday-stable IGO/gov configs. Keep in lockstep with - # frontend `STARTER_FEED_IDS` in `frontend/src/catalog/parseCatalog.ts`. - STARTER_FEED_IDS = %w[ - fao.org/newsroom - ftc.gov/press-releases - icrc.org/news - ].freeze + class << self + ## + # @return [Array] + def call + (embedded_entries + local_entries).sort_by(&:id) + end - module_function + private - ## - # @return [Array Object}>] - def call - embedded = Html2rss::Configs::Catalog.entries.map(&:to_h) - local = local_entries - (embedded + local).sort_by { |entry| entry.fetch(:id) } - end + # @return [Array] + def embedded_entries + Html2rss::Configs::Catalog.entries.map { |entry| join_last_result(entry.to_h) } + end - ## - # @return [Array Object}>] - def starter_entries - entries = call - selected = STARTER_FEED_IDS.filter_map { |id| entries.find { |entry| entry.fetch(:id) == id } } - selected.empty? ? entries.first(3) : selected - end + # @return [Array] + def local_entries + LocalConfig.feeds.filter_map do |feed_name, feed_config| + hash = build_local_hash(feed_name, feed_config) + next unless hash - ## - # @return [Array Object}>] - def local_entries - LocalConfig.feeds.filter_map do |feed_name, feed_config| - build_local_entry(feed_name, feed_config) + join_last_result(hash) + end end - end - ## - # @param feed_name [String, Symbol] - # @param feed_config [Hash] - # @return [Hash{Symbol => Object}, nil] - def build_local_entry(feed_name, feed_config) - directory = feed_config[:directory] || {} - title = directory[:title] - return nil if title.to_s.strip.empty? + # @param hash [Hash{Symbol => Object}] + # @return [Html2rss::Web::Catalog::Entry] + def join_last_result(hash) + Entry.from_hash(hash, last_result: Feeds::LastResults[hash.fetch(:id)]) + end - id = feed_name.to_s - channel = feed_config[:channel] || {} + # @param feed_name [String, Symbol] + # @param feed_config [Hash] + # @return [Hash{Symbol => Object}, nil] + def build_local_hash(feed_name, feed_config) + directory = feed_config[:directory] || {} + title = directory[:title] + return nil if title.to_s.strip.empty? - local_entry(id, directory, title, channel) - end + local_hash(feed_name.to_s, directory, title, feed_config) + end - ## - # @param id [String] - # @param directory [Hash] - # @param title [String] - # @param channel [Hash] - # @return [Hash{Symbol => Object}] - def local_entry(id, directory, title, channel) - { - id:, - path: "/#{id}.rss", - source: 'local', - directory: local_directory(directory, title), - channel: local_channel(channel, title), - parameters: { schema: {}, defaults: {} } - } - end + # @param id [String] + # @param directory [Hash] + # @param title [String] + # @param feed_config [Hash] + # @return [Hash{Symbol => Object}] + def local_hash(id, directory, title, feed_config) + { + id:, + path: "/#{id}.rss", + source: 'local', + directory: local_directory(directory, title), + channel: local_channel(feed_config[:channel] || {}, title), + parameters: local_parameters(feed_config) + } + end - ## - # @param directory [Hash] - # @param title [String] - # @return [Hash{Symbol => Object}] - def local_directory(directory, title) - { - title: title.to_s, - summary: directory[:summary], - topics: Array(directory[:topics]) - }.compact - end + # @param feed_config [Hash] + # @return [Hash{Symbol => Object}] + def local_parameters(feed_config) + { + schema: {}, + defaults: ParameterDefaults.extract(feed_config[:parameters]) + } + end - ## - # @param channel [Hash] - # @param title [String] - # @return [Hash{Symbol => Object}] - def local_channel(channel, title) - { - url: channel.fetch(:url), - language: channel[:language], - title: channel[:title] || title.to_s - }.compact + # @param directory [Hash] + # @param title [String] + # @return [Hash{Symbol => Object}] + def local_directory(directory, title) + { + title: title.to_s, + summary: directory[:summary], + topics: Array(directory[:topics]) + }.compact + end + + # @param channel [Hash] + # @param title [String] + # @return [Hash{Symbol => Object}] + def local_channel(channel, title) + { + url: channel.fetch(:url), + language: channel[:language], + title: channel[:title] || title.to_s + }.compact + end end end end diff --git a/app/web/catalog/parameter_defaults.rb b/app/web/catalog/parameter_defaults.rb new file mode 100644 index 00000000..b89dae48 --- /dev/null +++ b/app/web/catalog/parameter_defaults.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Html2rss + module Web + module Catalog + ## + # Sole owner of directory/catalog parameter-defaults expansion in the web + # app. Same algorithm as +Html2rss::Configs::Catalog+ +default_parameters+. + module ParameterDefaults + module_function + + ## + # Extracts string-keyed defaults from a feed +parameters+ block. + # + # @param parameters_block [Hash, nil] + # @return [Hash{String => Object}] + def extract(parameters_block) + return {} unless parameters_block.is_a?(Hash) + + parameters_block.each_with_object({}) do |(name, config), defaults| + next unless config.is_a?(Hash) && config.key?(:default) + + defaults[name.to_s] = config[:default] + end + end + end + end + end +end diff --git a/app/web/catalog/starters.rb b/app/web/catalog/starters.rb new file mode 100644 index 00000000..e9450d61 --- /dev/null +++ b/app/web/catalog/starters.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +module Html2rss + module Web + module Catalog + ## + # Sole owner of featured starter selection for +meta.starters+. + # + # Prefers +ok+, then +unknown+. Never features +empty+/+error+ when any + # preferred alternative exists. Cold-seed IDs break ties among equals only + # and are private to this module (not exported to clients). + module Starters + LIMIT = 3 + + COLD_SEED_IDS = %w[ + fao.org/newsroom + ftc.gov/press-releases + icrc.org/news + ].freeze + private_constant :COLD_SEED_IDS + + class << self + ## + # Picks up to +limit+ catalog entry ids for featured starters. + # + # @param entries [Array] + # @param limit [Integer] + # @return [Array] + def pick(entries, limit: LIMIT) + preferred = rank(select_states(entries, :ok)) + rank(select_states(entries, :unknown)) + chosen = preferred.first(limit) + return chosen.map(&:id) unless chosen.empty? + + rank(entries).first(limit).map(&:id) + end + + private + + # @param entries [Array] + # @param state [Symbol] + # @return [Array] + def select_states(entries, state) + entries.select { it.last_result.state == state } + end + + # Stable by cold-seed index then id. + # + # @param entries [Array] + # @return [Array] + def rank(entries) + entries.sort_by { |entry| [cold_seed_rank(entry.id), entry.id] } + end + + # @param id [String] + # @return [Integer] + def cold_seed_rank(id) + index = COLD_SEED_IDS.index(id) + index.nil? ? COLD_SEED_IDS.size : index + end + end + end + end + end +end diff --git a/app/web/feeds/contracts.rb b/app/web/feeds/contracts.rb index f6354b72..b3439a0b 100644 --- a/app/web/feeds/contracts.rb +++ b/app/web/feeds/contracts.rb @@ -34,7 +34,21 @@ def from_rack_request(request, target_kind:, identifier:) ## # Normalized source inputs for shared feed generation. - ResolvedSource = Data.define(:source_kind, :cache_identity, :generator_input, :ttl_seconds, :url, :strategy) + # + # +feed_name+, +directory_defaults+, and +request_params+ support + # directory-path {LastResults} recording for static feeds. Token sources + # use +feed_name+ nil and empty defaults/params bags. + ResolvedSource = Data.define( + :source_kind, + :cache_identity, + :generator_input, + :ttl_seconds, + :url, + :strategy, + :feed_name, + :directory_defaults, + :request_params + ) ## # Normalized feed payload consumed by renderers and HTTP responders. diff --git a/app/web/feeds/directory_params.rb b/app/web/feeds/directory_params.rb new file mode 100644 index 00000000..02f8dc3e --- /dev/null +++ b/app/web/feeds/directory_params.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Html2rss + module Web + module Feeds + ## + # Pure gate: request params match catalog/directory parameter defaults. + # + # Used so {LastResults} only records directory-path scrapes (defaults bag), + # not custom-parameterized hits. + module DirectoryParams + class << self + ## + # Returns true when +params+ has no keys beyond +defaults+ and every + # provided value equals the corresponding default (missing keys OK). + # + # @param defaults [Hash] + # @param params [Hash] + # @return [Boolean] + def match?(defaults, params) + default_bag = normalize_bag(defaults) + param_bag = normalize_bag(params) + return false unless (param_bag.keys - default_bag.keys).empty? + + default_bag.merge(param_bag) == default_bag + end + + private + + # @param bag [Hash] + # @return [Hash{String => String}] + def normalize_bag(bag) + bag.to_h.each_with_object({}) do |(key, value), out| + out[key.to_s] = value.nil? ? '' : value.to_s + end + end + end + end + end + end +end diff --git a/app/web/feeds/last_result.rb b/app/web/feeds/last_result.rb new file mode 100644 index 00000000..84566206 --- /dev/null +++ b/app/web/feeds/last_result.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Html2rss + module Web + module Feeds + ## + # Process-local last-known scrape outcome for a directory-path feed. + # + # +state+ mirrors {Contracts::RenderResult#status} plus +:unknown+ when + # the feed has never been scraped on defaults in this process. + LastResult = Data.define(:state, :code, :at) do + class << self + ## + # Cold / never-scraped projection. + # + # @return [Html2rss::Web::Feeds::LastResult] + def unknown + new(state: :unknown, code: nil, at: nil) + end + + ## + # Projects a real scrape {Contracts::RenderResult} into a last-result. + # + # @param render_result [Html2rss::Web::Feeds::Contracts::RenderResult] + # @param at [Time] + # @return [Html2rss::Web::Feeds::LastResult] + def from_render_result(render_result, at:) + new( + state: render_result.status, + code: render_result.decision&.code, + at: + ) + end + end + + ## + # Wire shape for catalog entries. + # + # @return [Hash{Symbol => Object}] + def to_h + { + state: state.to_s, + code:, + at: at&.utc&.iso8601 + } + end + end + end + end +end diff --git a/app/web/feeds/last_results.rb b/app/web/feeds/last_results.rb new file mode 100644 index 00000000..e7029f82 --- /dev/null +++ b/app/web/feeds/last_results.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require 'concurrent/map' + +module Html2rss + module Web + module Feeds + ## + # Process-local retention of directory-path {LastResult} values. + # + # Separate from {Cache}: outcomes outlive body TTL; cache hits do not + # refresh +at+. Not a Redis/store twin — best-effort per worker. + module LastResults + # rubocop:disable ThreadSafety/ClassInstanceVariable + def self.entries + @entries ||= Concurrent::Map.new + end + # rubocop:enable ThreadSafety/ClassInstanceVariable + private_class_method :entries + + class << self + ## + # Records a scrape outcome for +feed_name+. + # + # @param feed_name [String] + # @param render_result [Html2rss::Web::Feeds::Contracts::RenderResult] + # @param clock [Proc] injectible clock returning a {Time} + # @return [Html2rss::Web::Feeds::LastResult] + def record(feed_name, render_result, clock: -> { Time.now.utc }) + last = LastResult.from_render_result(render_result, at: clock.call) + entries[feed_name.to_s] = last + last + end + + ## + # @param feed_name [String] + # @return [Html2rss::Web::Feeds::LastResult] + def [](feed_name) + entries[feed_name.to_s] || LastResult.unknown + end + + ## + # @return [nil] + def clear! + entries.clear + nil + end + end + end + end + end +end diff --git a/app/web/feeds/service.rb b/app/web/feeds/service.rb index d2d59da7..f57fc690 100644 --- a/app/web/feeds/service.rb +++ b/app/web/feeds/service.rb @@ -27,6 +27,15 @@ def call(resolved_source) # @param cache_key [String] # @return [Html2rss::Web::Feeds::Contracts::RenderResult] def build_result(resolved_source, cache_key) + result = scrape_result(resolved_source, cache_key) + record_directory_last_result(resolved_source, result) + result + end + + # @param resolved_source [Html2rss::Web::Feeds::Contracts::ResolvedSource] + # @param cache_key [String] + # @return [Html2rss::Web::Feeds::Contracts::RenderResult] + def scrape_result(resolved_source, cache_key) feed_result = Html2rss.feed_result(resolved_source.generator_input) success_result(feed_result, resolved_source, cache_key) rescue StandardError => error @@ -39,6 +48,23 @@ def build_result(resolved_source, cache_key) error_result(error, decision, diagnostics, resolved_source, cache_key) end + # Records only static scrapes whose request params match directory defaults. + # Cache hits never reach this path (see {#call}). + # + # @param resolved_source [Html2rss::Web::Feeds::Contracts::ResolvedSource] + # @param result [Html2rss::Web::Feeds::Contracts::RenderResult] + # @return [void] + def record_directory_last_result(resolved_source, result) + return unless resolved_source.source_kind == :static + return if resolved_source.feed_name.to_s.empty? + return unless DirectoryParams.match?( + resolved_source.directory_defaults, + resolved_source.request_params + ) + + LastResults.record(resolved_source.feed_name, result) + end + # @param feed_result [Html2rss::FeedResult] # @param resolved_source [Html2rss::Web::Feeds::Contracts::ResolvedSource] # @param cache_key [String] diff --git a/app/web/feeds/source_resolver.rb b/app/web/feeds/source_resolver.rb index a90ae65e..393c9d20 100644 --- a/app/web/feeds/source_resolver.rb +++ b/app/web/feeds/source_resolver.rb @@ -28,16 +28,25 @@ def call(feed_request) # @return [Html2rss::Web::Feeds::Contracts::ResolvedSource] def resolve_static(feed_request) config = LocalConfig.find(feed_request.feed_name) - generator_input = static_generator_input(config, feed_request.params) + build_static_source(feed_request, config) + rescue Html2rss::Web::LocalConfig::NotFound + raise Html2rss::Web::NotFoundError + end - resolved_source_for( + # @param feed_request [Html2rss::Web::Feeds::Contracts::Request] + # @param config [Hash] + # @return [Html2rss::Web::Feeds::Contracts::ResolvedSource] + def build_static_source(feed_request, config) + generator_input = static_generator_input(config, feed_request.params) + resolved_source( source_kind: :static, cache_identity: static_cache_identity(feed_request.feed_name, feed_request.params), - generator_input: generator_input, - ttl_seconds: Cache.seconds_from_minutes(generator_input.dig(:channel, :ttl)) + generator_input:, + ttl_seconds: Cache.seconds_from_minutes(generator_input.dig(:channel, :ttl)), + feed_name: feed_request.feed_name, + directory_defaults: Catalog::ParameterDefaults.extract(config[:parameters]), + request_params: feed_request.params.to_h ) - rescue Html2rss::Web::LocalConfig::NotFound - raise Html2rss::Web::NotFoundError end # @param feed_request [Html2rss::Web::Feeds::Contracts::Request] @@ -45,30 +54,33 @@ def resolve_static(feed_request) def resolve_token(feed_request) ensure_auto_source_enabled! feed_token = authorize_feed_token!(feed_request.token) - strategy = resolved_strategy(feed_token) - generator_input = token_generator_input(feed_token.url, strategy) + build_token_source(feed_request, feed_token) + end - resolved_source_for( + # @param feed_request [Html2rss::Web::Feeds::Contracts::Request] + # @param feed_token [Html2rss::Web::FeedToken] + # @return [Html2rss::Web::Feeds::Contracts::ResolvedSource] + def build_token_source(feed_request, feed_token) + generator_input = token_generator_input(feed_token.url, resolved_strategy(feed_token)) + resolved_source( source_kind: :token, cache_identity: token_cache_identity(feed_request.token), - generator_input: generator_input, - ttl_seconds: Cache.seconds_from_minutes(generator_input.dig(:channel, :ttl), default: 300) + generator_input:, + ttl_seconds: Cache.seconds_from_minutes(generator_input.dig(:channel, :ttl), default: 300), + feed_name: nil, + directory_defaults: {}, + request_params: {} ) end - # @param source_kind [Symbol] - # @param cache_identity [String] - # @param generator_input [Hash{Symbol=>Object}] - # @param ttl_seconds [Integer] + # @param kwargs [Hash{Symbol=>Object}] # @return [Html2rss::Web::Feeds::Contracts::ResolvedSource] - def resolved_source_for(source_kind:, cache_identity:, generator_input:, ttl_seconds:) + def resolved_source(**kwargs) + generator_input = kwargs.fetch(:generator_input) Contracts::ResolvedSource.new( - source_kind:, - cache_identity:, - generator_input:, - ttl_seconds:, url: generator_input.dig(:channel, :url), - strategy: generator_input[:strategy] + strategy: generator_input[:strategy], + **kwargs ) end diff --git a/public/openapi.yaml b/public/openapi.yaml index 68b5e713..928a8b58 100644 --- a/public/openapi.yaml +++ b/public/openapi.yaml @@ -129,6 +129,19 @@ paths: type: object id: type: string + last_result: + properties: + at: + type: 'null' + code: + type: 'null' + state: + type: string + required: + - state + - code + - at + type: object parameters: properties: defaults: @@ -243,6 +256,7 @@ paths: - directory - channel - parameters + - last_result type: object type: array required: @@ -252,11 +266,16 @@ paths: properties: catalog_version: type: integer + starters: + items: + type: string + type: array total: type: integer required: - total - catalog_version + - starters type: object success: type: boolean diff --git a/spec/html2rss/web/api/v1_spec.rb b/spec/html2rss/web/api/v1_spec.rb index e037715f..4a1662a7 100644 --- a/spec/html2rss/web/api/v1_spec.rb +++ b/spec/html2rss/web/api/v1_spec.rb @@ -219,9 +219,14 @@ def relative_feed_link_header(token) expect(last_response.status).to eq(200) expect(last_response.headers['Access-Control-Allow-Origin']).to eq('*') json = expect_success_response(last_response) - expect(json.dig('meta', 'catalog_version')).to eq(1) + expect(json.dig('meta', 'catalog_version')).to eq(2) + expect(json.dig('meta', 'starters')).to be_an(Array) + expect(json.dig('meta', 'starters').size).to be <= 3 expect(json.dig('data', 'configs')).to be_an(Array) - expect(json.dig('data', 'configs').first).to include('id', 'path', 'source', 'directory', 'channel', 'parameters') + expect(json.dig('data', 'configs').first).to include( + 'id', 'path', 'source', 'directory', 'channel', 'parameters', 'last_result' + ) + expect(json.dig('data', 'configs').first.fetch('last_result')).to include('state', 'code', 'at') end it 'returns 404 when the catalog is disabled', :aggregate_failures do diff --git a/spec/html2rss/web/catalog/merge_spec.rb b/spec/html2rss/web/catalog/merge_spec.rb index f46d7a62..785ca02a 100644 --- a/spec/html2rss/web/catalog/merge_spec.rb +++ b/spec/html2rss/web/catalog/merge_spec.rb @@ -1,24 +1,32 @@ # frozen_string_literal: true require 'spec_helper' - require_relative '../../../../app' RSpec.describe Html2rss::Web::Catalog::Merge do - describe 'STARTER_FEED_IDS' do - it 'prefers Faraday-stable IGO and gov configs' do - expect(described_class::STARTER_FEED_IDS).to eq( - %w[ - fao.org/newsroom - ftc.gov/press-releases - icrc.org/news - ] - ) - end + before { Html2rss::Web::Feeds::LastResults.clear! } + + it 'returns Catalog::Entry rows with required last_result', :aggregate_failures do + entries = described_class.call + + expect(entries).to all(be_a(Html2rss::Web::Catalog::Entry)) + expect(entries.map(&:id)).to include('fao.org/newsroom', 'ftc.gov/press-releases', 'icrc.org/news') + expect(entries.first.last_result).to eq(Html2rss::Web::Feeds::LastResult.unknown) + expect(entries.first.to_h.fetch(:last_result)).to eq(state: 'unknown', code: nil, at: nil) + end + + it 'joins recorded LastResults onto matching ids' do + render = Html2rss::Web::Feeds::Contracts::RenderResult.new( + status: :ok, + payload: nil, + ttl_seconds: 60, + cache_key: 'k', + decision: nil + ) + Html2rss::Web::Feeds::LastResults.record('fao.org/newsroom', render, clock: -> { Time.utc(2026, 8, 29, 8) }) - it 'resolves each preferred id in the merged catalog' do - catalog_ids = described_class.call.map { |entry| entry.fetch(:id) } - expect(catalog_ids).to include(*described_class::STARTER_FEED_IDS) - end + entry = described_class.call.find { it.id == 'fao.org/newsroom' } + expect(entry.last_result).to have_attributes(state: :ok, code: nil) + expect(entry.last_result.at).to eq(Time.utc(2026, 8, 29, 8)) end end diff --git a/spec/html2rss/web/catalog/starters_spec.rb b/spec/html2rss/web/catalog/starters_spec.rb new file mode 100644 index 00000000..423ce11e --- /dev/null +++ b/spec/html2rss/web/catalog/starters_spec.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../../../../app' + +RSpec.describe Html2rss::Web::Catalog::Starters do + def entry(id, state) + Html2rss::Web::Catalog::Entry.new( + id:, + path: "/#{id}.rss", + source: 'embedded', + directory: { title: id }, + channel: { url: "https://#{id}" }, + parameters: { schema: {}, defaults: {} }, + last_result: Html2rss::Web::Feeds::LastResult.new(state:, code: nil, at: nil) + ) + end + + it 'prefers ok over unknown and never picks empty/error when alternatives exist', :aggregate_failures do + entries = [ + entry('z.error', :error), + entry('y.empty', :empty), + entry('fao.org/newsroom', :unknown), + entry('a.ok', :ok), + entry('b.ok', :ok) + ] + + expect(described_class.pick(entries)).to eq(%w[a.ok b.ok fao.org/newsroom]) + end + + it 'uses cold-seed order only as a tie-break among unknown', :aggregate_failures do + entries = [ + entry('zzz.org/feed', :unknown), + entry('icrc.org/news', :unknown), + entry('fao.org/newsroom', :unknown), + entry('ftc.gov/press-releases', :unknown) + ] + + expect(described_class.pick(entries)).to eq( + %w[fao.org/newsroom ftc.gov/press-releases icrc.org/news] + ) + end + + it 'falls back to failing rows only when the catalog has nothing else' do + entries = [entry('only.empty', :empty), entry('only.error', :error)] + + expect(described_class.pick(entries)).to eq(%w[only.empty only.error]) + end +end diff --git a/spec/html2rss/web/feeds/directory_params_spec.rb b/spec/html2rss/web/feeds/directory_params_spec.rb new file mode 100644 index 00000000..c87f6665 --- /dev/null +++ b/spec/html2rss/web/feeds/directory_params_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../../../../app' + +RSpec.describe Html2rss::Web::Feeds::DirectoryParams do + it 'matches when params equal defaults', :aggregate_failures do + expect(described_class.match?({ 'id' => 'b006wkfp' }, { 'id' => 'b006wkfp' })).to be(true) + expect(described_class.match?({}, {})).to be(true) + end + + it 'matches when request omits keys that have defaults' do + expect(described_class.match?({ id: 'b006wkfp' }, {})).to be(true) + end + + it 'rejects extra or non-default values', :aggregate_failures do + expect(described_class.match?({ 'id' => 'b006wkfp' }, { 'id' => 'other' })).to be(false) + expect(described_class.match?({}, { 'page' => '2' })).to be(false) + end +end diff --git a/spec/html2rss/web/feeds/feed_result_integration_spec.rb b/spec/html2rss/web/feeds/feed_result_integration_spec.rb index fd8fe677..2cf283c4 100644 --- a/spec/html2rss/web/feeds/feed_result_integration_spec.rb +++ b/spec/html2rss/web/feeds/feed_result_integration_spec.rb @@ -12,7 +12,10 @@ generator_input: generator_input, ttl_seconds: 600, url: page_url, - strategy: :faraday + strategy: :faraday, + feed_name: nil, + directory_defaults: {}, + request_params: {} ) end let(:generator_input) do diff --git a/spec/html2rss/web/feeds/last_results_spec.rb b/spec/html2rss/web/feeds/last_results_spec.rb new file mode 100644 index 00000000..0916f8ae --- /dev/null +++ b/spec/html2rss/web/feeds/last_results_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../../../../app' + +RSpec.describe Html2rss::Web::Feeds::LastResults do + before { described_class.clear! } + + let(:ok_result) do + Html2rss::Web::Feeds::Contracts::RenderResult.new( + status: :ok, + payload: nil, + ttl_seconds: 60, + cache_key: 'k' + ) + end + + it 'returns unknown until recorded' do + expect(described_class['missing']).to eq(Html2rss::Web::Feeds::LastResult.unknown) + end + + it 'records render outcomes with an injectable clock', :aggregate_failures do + at = Time.utc(2026, 8, 29, 12) + last = described_class.record('feed.id', ok_result, clock: -> { at }) + + expect(last).to have_attributes(state: :ok, code: nil, at:) + expect(described_class['feed.id']).to eq(last) + expect(last.to_h).to eq(state: 'ok', code: nil, at: '2026-08-29T12:00:00Z') + end +end diff --git a/spec/html2rss/web/feeds/service_spec.rb b/spec/html2rss/web/feeds/service_spec.rb index 893f40df..bd6aaae3 100644 --- a/spec/html2rss/web/feeds/service_spec.rb +++ b/spec/html2rss/web/feeds/service_spec.rb @@ -16,12 +16,16 @@ }, ttl_seconds: 900, url: 'https://example.com/articles', - strategy: nil + strategy: nil, + feed_name: 'example.com/articles', + directory_defaults: {}, + request_params: {} ) end before do Html2rss::Web::Feeds::Cache.clear! + Html2rss::Web::Feeds::LastResults.clear! allow(Html2rss::Web::Feeds::ChannelTitle).to receive(:for) .with('https://example.com/articles') .and_return('Example Feed') @@ -56,6 +60,41 @@ expect(result.payload.site_title).to eq('Channel From Scrape') end + it 'records last_result for directory-defaults static scrapes', :aggregate_failures do + allow(Html2rss).to receive(:feed_result).with(resolved_source.generator_input).and_return(feed_result) + + described_class.call(resolved_source) + + expect(Html2rss::Web::Feeds::LastResults['example.com/articles'].state).to eq(:ok) + end + + it 'does not refresh last_result on cache hits' do + allow(Html2rss).to receive(:feed_result).with(resolved_source.generator_input).and_return(feed_result) + described_class.call(resolved_source) + first_at = Html2rss::Web::Feeds::LastResults['example.com/articles'].at + + travel = first_at + 60 + allow(Time).to receive(:now).and_return(travel) + described_class.call(resolved_source) + + expect(Html2rss::Web::Feeds::LastResults['example.com/articles'].at).to eq(first_at) + end + + it 'does not record when request params diverge from directory defaults' do + custom = Html2rss::Web::Feeds::Contracts::ResolvedSource.new( + **resolved_source.to_h, + directory_defaults: { 'id' => 'default' }, + request_params: { 'id' => 'custom' } + ) + allow(Html2rss).to receive(:feed_result).with(custom.generator_input).and_return(feed_result) + + described_class.call(custom) + + expect(Html2rss::Web::Feeds::LastResults['example.com/articles']).to eq( + Html2rss::Web::Feeds::LastResult.unknown + ) + end + it 'reuses the cached result for repeated requests' do described_class.call(resolved_source) described_class.call(resolved_source) diff --git a/spec/html2rss/web/feeds/source_resolver_spec.rb b/spec/html2rss/web/feeds/source_resolver_spec.rb index fcb43545..b8c6fa1f 100644 --- a/spec/html2rss/web/feeds/source_resolver_spec.rb +++ b/spec/html2rss/web/feeds/source_resolver_spec.rb @@ -63,6 +63,17 @@ def resolved_tuple(resolved) expect(resolved.generator_input[:strategy]).to eq(:botasaurus) expect(resolved).to have_attributes(url: 'https://example.com/feed', strategy: :botasaurus) end + + it 'exposes directory defaults and request params for last-result gating', :aggregate_failures do + config[:parameters] = { id: { type: 'string', default: 'b006wkfp' } } + resolved = described_class.call(feed_request) + + expect(resolved).to have_attributes( + feed_name: 'legacy', + directory_defaults: { 'id' => 'b006wkfp' }, + request_params: { 'page' => '3' } + ) + end end context 'with a token request' do From 70a1b5df3f7e541020aedc95209706f8728cfb44 Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sat, 29 Aug 2026 12:10:26 +0200 Subject: [PATCH 2/4] feat(frontend): catalog starters from meta.starters Parse catalog_version 2 last_result and meta.starters, delete the lockstep STARTER_FEED_IDS list, and demote empty/error rows in catalog find ranking. --- frontend/src/__tests__/App.test.tsx | 168 ++++++++++++--------- frontend/src/__tests__/catalog.test.ts | 68 +++++++-- frontend/src/__tests__/mocks/server.ts | 2 +- frontend/src/__tests__/useSession.test.ts | 5 +- frontend/src/api/generated/types.gen.ts | 6 + frontend/src/catalog/findCatalogEntries.ts | 19 ++- frontend/src/catalog/index.ts | 5 +- frontend/src/catalog/parseCatalog.ts | 109 +++++++++++-- frontend/src/catalog/types.ts | 19 +++ frontend/src/catalog/useCatalogEntries.ts | 26 ++-- frontend/src/session/useSession.ts | 8 +- 11 files changed, 318 insertions(+), 117 deletions(-) diff --git a/frontend/src/__tests__/App.test.tsx b/frontend/src/__tests__/App.test.tsx index db63d4a6..3604dc02 100644 --- a/frontend/src/__tests__/App.test.tsx +++ b/frontend/src/__tests__/App.test.tsx @@ -18,7 +18,7 @@ vi.mock('../hooks/useApiMetadata', () => ({ })); vi.mock('../catalog/useCatalogEntries', () => ({ - useCatalogEntries: vi.fn(() => []), + useCatalogEntries: vi.fn(() => ({ entries: [], starters: [] })), })); import { useAccessToken } from '../session/accessToken'; @@ -30,6 +30,12 @@ const mockUseAccessToken = useAccessToken as any; const mockUseApiMetadata = useApiMetadata as any; const mockUseFeedCreation = useFeedCreation as any; const mockUseCatalogEntries = useCatalogEntries as any; + +const catalogHook = (entries: unknown[] = [], starters?: string[]) => ({ + entries, + starters: starters ?? entries.map((entry: any) => entry.id).slice(0, 3), +}); + const mockCreatedFeedResult = { feed: { id: 'feed-123', @@ -77,7 +83,7 @@ describe('App', () => { mockClearCreationError.mockImplementation(() => { creationHookError = undefined; }); - mockUseCatalogEntries.mockReturnValue([]); + mockUseCatalogEntries.mockReturnValue(catalogHook()); mockUseAccessToken.mockReturnValue({ token: undefined, @@ -368,10 +374,11 @@ describe('App', () => { description: 'News and media from the Food and Agriculture Organization.', channelUrl: 'https://www.fao.org/newsroom', parameterDefaults: {}, + lastResult: { state: 'unknown' }, }; it('shows lean included-feed starters on empty create when creation is enabled', async () => { - mockUseCatalogEntries.mockReturnValue([faoStarter]); + mockUseCatalogEntries.mockReturnValue(catalogHook([faoStarter], ['fao.org/newsroom'])); mockUseAccessToken.mockReturnValue({ token: 'session-token', hasToken: true, @@ -410,7 +417,7 @@ describe('App', () => { }); it('hides included-feed starters when the URL field is non-empty', async () => { - mockUseCatalogEntries.mockReturnValue([faoStarter]); + mockUseCatalogEntries.mockReturnValue(catalogHook([faoStarter], ['fao.org/newsroom'])); mockUseAccessToken.mockReturnValue({ token: 'session-token', hasToken: true, @@ -439,17 +446,20 @@ describe('App', () => { }); it('hides included-feed starters when catalog find has hits', async () => { - mockUseCatalogEntries.mockReturnValue([ - faoStarter, - { - id: 'anthropic.com/news', - path: '/anthropic.com/news.rss', - title: 'Anthropic — News', - description: 'Product and research announcements from Anthropic.', - channelUrl: 'https://www.anthropic.com/news', - parameterDefaults: {}, - }, - ]); + mockUseCatalogEntries.mockReturnValue( + catalogHook([ + faoStarter, + { + id: 'anthropic.com/news', + path: '/anthropic.com/news.rss', + title: 'Anthropic — News', + description: 'Product and research announcements from Anthropic.', + channelUrl: 'https://www.anthropic.com/news', + parameterDefaults: {}, + lastResult: { state: 'unknown' }, + }, + ]) + ); mockUseAccessToken.mockReturnValue({ token: 'session-token', hasToken: true, @@ -478,7 +488,7 @@ describe('App', () => { }); it('promotes included feeds when feed creation is disabled', async () => { - mockUseCatalogEntries.mockReturnValue([faoStarter]); + mockUseCatalogEntries.mockReturnValue(catalogHook([faoStarter], ['fao.org/newsroom'])); mockUseApiMetadata.mockReturnValue({ metadata: { @@ -518,16 +528,19 @@ describe('App', () => { }); it('suggests included feeds when the URL matches the catalog', async () => { - mockUseCatalogEntries.mockReturnValue([ - { - id: 'anthropic.com/news', - path: '/anthropic.com/news.rss', - title: 'Anthropic — News', - description: 'Product and research announcements from Anthropic.', - channelUrl: 'https://www.anthropic.com/news', - parameterDefaults: {}, - }, - ]); + mockUseCatalogEntries.mockReturnValue( + catalogHook([ + { + id: 'anthropic.com/news', + path: '/anthropic.com/news.rss', + title: 'Anthropic — News', + description: 'Product and research announcements from Anthropic.', + channelUrl: 'https://www.anthropic.com/news', + parameterDefaults: {}, + lastResult: { state: 'unknown' }, + }, + ]) + ); render(); @@ -545,24 +558,28 @@ describe('App', () => { }); it('lists multiple catalog find hits for a text query including defaults href', async () => { - mockUseCatalogEntries.mockReturnValue([ - { - id: 'bbc.com/mundo', - path: '/bbc.com/mundo.rss', - title: 'BBC — Mundo', - description: 'Spanish-language news from BBC Mundo.', - channelUrl: 'https://www.bbc.com/mundo', - parameterDefaults: {}, - }, - { - id: 'bbc.co.uk/available_episodes', - path: '/bbc.co.uk/available_episodes.rss', - title: 'BBC Sounds — Programme episodes', - description: 'Available episodes for a BBC programme on Sounds.', - channelUrl: 'https://www.bbc.co.uk/programmes/%s/episodes/player', - parameterDefaults: { id: 'b006wkfp' }, - }, - ]); + mockUseCatalogEntries.mockReturnValue( + catalogHook([ + { + id: 'bbc.com/mundo', + path: '/bbc.com/mundo.rss', + title: 'BBC — Mundo', + description: 'Spanish-language news from BBC Mundo.', + channelUrl: 'https://www.bbc.com/mundo', + parameterDefaults: {}, + lastResult: { state: 'unknown' }, + }, + { + id: 'bbc.co.uk/available_episodes', + path: '/bbc.co.uk/available_episodes.rss', + title: 'BBC Sounds — Programme episodes', + description: 'Available episodes for a BBC programme on Sounds.', + channelUrl: 'https://www.bbc.co.uk/programmes/%s/episodes/player', + parameterDefaults: { id: 'b006wkfp' }, + lastResult: { state: 'unknown' }, + }, + ]) + ); render(); @@ -590,24 +607,28 @@ describe('App', () => { const assignSpy = vi.fn(); vi.stubGlobal('location', { ...location, assign: assignSpy }); - mockUseCatalogEntries.mockReturnValue([ - { - id: 'bbc.com/mundo', - path: '/bbc.com/mundo.rss', - title: 'BBC — Mundo', - description: 'Spanish-language news from BBC Mundo.', - channelUrl: 'https://www.bbc.com/mundo', - parameterDefaults: {}, - }, - { - id: 'bbc.co.uk/available_episodes', - path: '/bbc.co.uk/available_episodes.rss', - title: 'BBC Sounds — Programme episodes', - description: 'Available episodes for a BBC programme on Sounds.', - channelUrl: 'https://www.bbc.co.uk/programmes/%s/episodes/player', - parameterDefaults: { id: 'b006wkfp' }, - }, - ]); + mockUseCatalogEntries.mockReturnValue( + catalogHook([ + { + id: 'bbc.com/mundo', + path: '/bbc.com/mundo.rss', + title: 'BBC — Mundo', + description: 'Spanish-language news from BBC Mundo.', + channelUrl: 'https://www.bbc.com/mundo', + parameterDefaults: {}, + lastResult: { state: 'unknown' }, + }, + { + id: 'bbc.co.uk/available_episodes', + path: '/bbc.co.uk/available_episodes.rss', + title: 'BBC Sounds — Programme episodes', + description: 'Available episodes for a BBC programme on Sounds.', + channelUrl: 'https://www.bbc.co.uk/programmes/%s/episodes/player', + parameterDefaults: { id: 'b006wkfp' }, + lastResult: { state: 'unknown' }, + }, + ]) + ); render(); @@ -641,16 +662,19 @@ describe('App', () => { error: undefined, }); - mockUseCatalogEntries.mockReturnValue([ - { - id: 'bbc.com/mundo', - path: '/bbc.com/mundo.rss', - title: 'BBC — Mundo', - description: 'Spanish-language news from BBC Mundo.', - channelUrl: 'https://www.bbc.com/mundo', - parameterDefaults: {}, - }, - ]); + mockUseCatalogEntries.mockReturnValue( + catalogHook([ + { + id: 'bbc.com/mundo', + path: '/bbc.com/mundo.rss', + title: 'BBC — Mundo', + description: 'Spanish-language news from BBC Mundo.', + channelUrl: 'https://www.bbc.com/mundo', + parameterDefaults: {}, + lastResult: { state: 'unknown' }, + }, + ]) + ); render(); diff --git a/frontend/src/__tests__/catalog.test.ts b/frontend/src/__tests__/catalog.test.ts index 67f88a59..34e9b427 100644 --- a/frontend/src/__tests__/catalog.test.ts +++ b/frontend/src/__tests__/catalog.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { catalogFeedHref, findCatalogEntries, parseCatalogEntries, selectStarterFeeds } from '../catalog'; +import { catalogFeedHref, findCatalogEntries, parseCatalog, selectStarterFeeds } from '../catalog'; import type { CatalogEntry } from '../catalog'; +import { UNKNOWN_LAST_RESULT } from '../catalog'; const baseEntry = ( overrides: Partial & Pick @@ -9,6 +10,7 @@ const baseEntry = ( title: overrides.title ?? overrides.id, description: overrides.description ?? '', parameterDefaults: overrides.parameterDefaults ?? {}, + lastResult: overrides.lastResult ?? UNKNOWN_LAST_RESULT, ...overrides, }); @@ -85,11 +87,22 @@ describe('findCatalogEntries', () => { expect(hits[0]?.id).toBe('exact.example/path'); expect(findCatalogEntries('news', many)).toHaveLength(5); }); + + it('demotes empty/error text hits below ok/unknown', () => { + const failing = baseEntry({ + id: 'bbc.com/broken', + channelUrl: 'https://www.bbc.com/broken', + title: 'BBC Broken', + lastResult: { state: 'error', code: 'UPSTREAM', at: '2026-08-29T08:00:00Z' }, + }); + const hits = findCatalogEntries('bbc', [failing, mundo]); + expect(hits.map((entry) => entry.id)).toEqual(['bbc.com/mundo', 'bbc.com/broken']); + }); }); -describe('parseCatalogEntries', () => { - it('maps wire rows with parameterDefaults and drops invalid ones', () => { - const entries = parseCatalogEntries({ +describe('parseCatalog', () => { + it('maps v2 wire rows with last_result and meta.starters', () => { + const snapshot = parseCatalog({ data: { configs: [ { @@ -98,6 +111,7 @@ describe('parseCatalogEntries', () => { channel: { url: 'https://www.anthropic.com/news' }, directory: { title: 'Anthropic — News', summary: 'Announcements.' }, parameters: { schema: {}, defaults: {} }, + last_result: { state: 'unknown' }, }, { id: 'bbc.co.uk/available_episodes', @@ -105,13 +119,20 @@ describe('parseCatalogEntries', () => { channel: { url: 'https://www.bbc.co.uk/programmes/%s/episodes/player' }, directory: { title: 'BBC Sounds — Programme episodes', summary: 'Episodes.' }, parameters: { schema: { id: { type: 'string' } }, defaults: { id: 'b006wkfp' } }, + last_result: { state: 'ok', at: '2026-08-29T08:00:00Z' }, }, { id: 'broken' }, ], }, + meta: { + total: 2, + catalog_version: 2, + starters: ['bbc.co.uk/available_episodes', 'anthropic.com/news'], + }, }); - expect(entries).toEqual([ + expect(snapshot.starters).toEqual(['bbc.co.uk/available_episodes', 'anthropic.com/news']); + expect(snapshot.entries).toEqual([ { id: 'anthropic.com/news', path: '/anthropic.com/news.rss', @@ -119,6 +140,7 @@ describe('parseCatalogEntries', () => { description: 'Announcements.', channelUrl: 'https://www.anthropic.com/news', parameterDefaults: {}, + lastResult: { state: 'unknown' }, }, { id: 'bbc.co.uk/available_episodes', @@ -127,21 +149,45 @@ describe('parseCatalogEntries', () => { description: 'Episodes.', channelUrl: 'https://www.bbc.co.uk/programmes/%s/episodes/player', parameterDefaults: { id: 'b006wkfp' }, + lastResult: { state: 'ok', at: '2026-08-29T08:00:00Z' }, }, ]); }); + + it('fail-closes on catalog_version 1', () => { + expect( + parseCatalog({ + data: { configs: [{ id: 'x', path: '/x.rss', channel: { url: 'https://x' } }] }, + meta: { total: 1, catalog_version: 1 }, + }) + ).toEqual({ entries: [], starters: [] }); + }); }); describe('selectStarterFeeds', () => { - it('prefers known starter ids then falls back to the first three', () => { + it('maps meta.starters ids onto entries', () => { const fao = baseEntry({ id: 'fao.org/newsroom', channelUrl: 'https://fao.example' }); const other = baseEntry({ id: 'other.com/feed', channelUrl: 'https://other.example' }); - expect(selectStarterFeeds([other, fao]).map((entry) => entry.id)).toEqual(['fao.org/newsroom']); - expect(selectStarterFeeds([other]).map((entry) => entry.id)).toEqual(['other.com/feed']); + expect(selectStarterFeeds([other, fao], ['fao.org/newsroom']).map((entry) => entry.id)).toEqual([ + 'fao.org/newsroom', + ]); }); - it('exports STARTER_FEED_IDS for lockstep assertions', async () => { - const { STARTER_FEED_IDS } = await import('../catalog'); - expect([...STARTER_FEED_IDS]).toEqual(['fao.org/newsroom', 'ftc.gov/press-releases', 'icrc.org/news']); + it('falls back to last_result ranking and skips failing when alternatives exist', () => { + const ok = baseEntry({ + id: 'ok.com/feed', + channelUrl: 'https://ok.example', + lastResult: { state: 'ok' }, + }); + const failing = baseEntry({ + id: 'bad.com/feed', + channelUrl: 'https://bad.example', + lastResult: { state: 'error', code: 'X' }, + }); + const unknown = baseEntry({ id: 'cold.com/feed', channelUrl: 'https://cold.example' }); + expect(selectStarterFeeds([failing, unknown, ok], []).map((entry) => entry.id)).toEqual([ + 'ok.com/feed', + 'cold.com/feed', + ]); }); }); diff --git a/frontend/src/__tests__/mocks/server.ts b/frontend/src/__tests__/mocks/server.ts index 67aac8d8..87a10337 100644 --- a/frontend/src/__tests__/mocks/server.ts +++ b/frontend/src/__tests__/mocks/server.ts @@ -28,7 +28,7 @@ export const server = setupServer( return HttpResponse.json({ success: true, data: { configs: [] }, - meta: { total: 0, catalog_version: 1 }, + meta: { total: 0, catalog_version: 2, starters: [] }, }); }) ); diff --git a/frontend/src/__tests__/useSession.test.ts b/frontend/src/__tests__/useSession.test.ts index edc2b1b2..74ff83de 100644 --- a/frontend/src/__tests__/useSession.test.ts +++ b/frontend/src/__tests__/useSession.test.ts @@ -19,7 +19,7 @@ describe('useSession', () => { const emptyCatalogResponse = Response.json({ success: true, data: { configs: [] }, - meta: { total: 0, catalog_version: 1 }, + meta: { total: 0, catalog_version: 2, starters: [] }, }); const mockFetchFor = (metadata: unknown, catalogResponse: Response = emptyCatalogResponse) => { @@ -72,13 +72,14 @@ describe('useSession', () => { channel: { url: 'https://www.fao.org/newsroom' }, directory: { title: 'FAO Newsroom', summary: 'News' }, parameters: { defaults: {} }, + last_result: { state: 'unknown' }, }; mockFetchFor( mockMetadata, Response.json({ success: true, data: { configs: [fao] }, - meta: { total: 1, catalog_version: 1 }, + meta: { total: 1, catalog_version: 2, starters: ['fao.org/newsroom'] }, }) ); diff --git a/frontend/src/api/generated/types.gen.ts b/frontend/src/api/generated/types.gen.ts index 2939a0d7..da532de8 100644 --- a/frontend/src/api/generated/types.gen.ts +++ b/frontend/src/api/generated/types.gen.ts @@ -75,6 +75,11 @@ export type GetConfigCatalogResponses = { topics: Array; }; id: string; + last_result: { + at: null; + code: null; + state: string; + }; parameters: { defaults: { blog?: string | null; @@ -115,6 +120,7 @@ export type GetConfigCatalogResponses = { }; meta: { catalog_version: number; + starters: Array; total: number; }; success: boolean; diff --git a/frontend/src/catalog/findCatalogEntries.ts b/frontend/src/catalog/findCatalogEntries.ts index 80df0b1e..a07f7708 100644 --- a/frontend/src/catalog/findCatalogEntries.ts +++ b/frontend/src/catalog/findCatalogEntries.ts @@ -68,6 +68,18 @@ function isTextMatch(entry: CatalogEntry, needle: string): boolean { return haystacks.some((value) => value.toLowerCase().includes(needle)); } +function isFailing(entry: CatalogEntry): boolean { + const state = entry.lastResult.state; + return state === 'empty' || state === 'error'; +} + +function compareFindHits(a: CatalogEntry, b: CatalogEntry): number { + const failA = isFailing(a) ? 1 : 0; + const failB = isFailing(b) ? 1 : 0; + if (failA !== failB) return failA - failB; + return a.id.localeCompare(b.id); +} + /** * Relative feed href with `parameters.defaults` applied as query params. */ @@ -79,7 +91,8 @@ export function catalogFeedHref(entry: CatalogEntry): string { /** * Finds catalog entries for a create-field query: URL equivalence hits first, - * then case-insensitive substring text hits. Deduped by `id`, capped at 5. + * then case-insensitive substring text hits. Within each bucket, demotes + * empty/error below ok/unknown. Deduped by `id`, capped at 5. */ export function findCatalogEntries(query: string, entries: readonly CatalogEntry[]): readonly CatalogEntry[] { const trimmed = query.trim(); @@ -121,7 +134,7 @@ export function findCatalogEntries(query: string, entries: readonly CatalogEntry } } - urlHits.sort((a, b) => a.id.localeCompare(b.id)); - textHits.sort((a, b) => a.id.localeCompare(b.id)); + urlHits.sort(compareFindHits); + textHits.sort(compareFindHits); return [...urlHits, ...textHits].slice(0, FIND_CAP); } diff --git a/frontend/src/catalog/index.ts b/frontend/src/catalog/index.ts index 44b96659..bba22332 100644 --- a/frontend/src/catalog/index.ts +++ b/frontend/src/catalog/index.ts @@ -1,4 +1,5 @@ -export type { CatalogEntry } from './types'; +export type { CatalogEntry, CatalogSnapshot, LastResult, LastResultState } from './types'; +export { UNKNOWN_LAST_RESULT } from './types'; export { findCatalogEntries, catalogFeedHref } from './findCatalogEntries'; -export { parseCatalogEntries, selectStarterFeeds, STARTER_FEED_IDS } from './parseCatalog'; +export { parseCatalog, parseCatalogEntries, selectStarterFeeds } from './parseCatalog'; export { useCatalogEntries } from './useCatalogEntries'; diff --git a/frontend/src/catalog/parseCatalog.ts b/frontend/src/catalog/parseCatalog.ts index d43423cf..083eaed8 100644 --- a/frontend/src/catalog/parseCatalog.ts +++ b/frontend/src/catalog/parseCatalog.ts @@ -1,4 +1,7 @@ -import type { CatalogEntry } from './types'; +import type { CatalogEntry, CatalogSnapshot, LastResult, LastResultState } from './types'; + +const SUPPORTED_CATALOG_VERSION = 2; +const STARTER_LIMIT = 3; interface CatalogWireEntry { id?: unknown; @@ -6,10 +9,12 @@ interface CatalogWireEntry { channel?: { url?: unknown }; directory?: { title?: unknown; summary?: unknown }; parameters?: { defaults?: unknown }; + last_result?: unknown; } interface CatalogEnvelope { data?: { configs?: unknown }; + meta?: { catalog_version?: unknown; starters?: unknown }; } function isRecord(value: unknown): value is Record { @@ -30,14 +35,67 @@ function parseParameterDefaults(value: unknown): Readonly return defaults; } +const LAST_RESULT_STATES = new Set(['ok', 'empty', 'error', 'unknown']); + +function parseLastResult(value: unknown): LastResult | undefined { + if (!isRecord(value)) return undefined; + const state = value.state; + if (typeof state !== 'string' || !LAST_RESULT_STATES.has(state as LastResultState)) { + return undefined; + } + const code = value.code; + if (!(code === null || typeof code === 'string' || code === undefined)) return undefined; + const at = value.at; + if (!(at === null || typeof at === 'string' || at === undefined)) return undefined; + return { + state: state as LastResultState, + ...(typeof code === 'string' && { code }), + ...(typeof at === 'string' && { at }), + }; +} + +function parseStarterIds(value: unknown): readonly string[] { + if (!Array.isArray(value)) return []; + return value.filter((id): id is string => typeof id === 'string' && id.trim().length > 0); +} + +function isFailingState(state: LastResultState): boolean { + return state === 'empty' || state === 'error'; +} + +function starterRank(entry: CatalogEntry): number { + switch (entry.lastResult.state) { + case 'ok': { + return 0; + } + case 'unknown': { + return 1; + } + case 'empty': { + return 2; + } + case 'error': { + return 3; + } + default: { + return 4; + } + } +} + /** - * Maps a catalog API envelope to domain entries. Invalid rows are dropped. + * Maps a catalog API envelope to domain entries + meta.starters. + * Fail-closed on catalog_version other than 2; invalid rows are dropped. */ -export function parseCatalogEntries(payload: unknown): CatalogEntry[] { - if (!isRecord(payload)) return []; +export function parseCatalog(payload: unknown): CatalogSnapshot { + if (!isRecord(payload)) return { entries: [], starters: [] }; const envelope = payload as CatalogEnvelope; + if (envelope.meta?.catalog_version !== SUPPORTED_CATALOG_VERSION) { + return { entries: [], starters: [] }; + } + const configs = envelope.data?.configs; - if (!Array.isArray(configs)) return []; + if (!Array.isArray(configs)) return { entries: [], starters: parseStarterIds(envelope.meta?.starters) }; const entries: CatalogEntry[] = []; for (const row of configs) { @@ -46,7 +104,8 @@ export function parseCatalogEntries(payload: unknown): CatalogEntry[] { const id = asString(wire.id); const path = asString(wire.path); const channelUrl = asString(wire.channel?.url); - if (!id || !path || !channelUrl) continue; + const lastResult = parseLastResult(wire.last_result); + if (!id || !path || !channelUrl || !lastResult) continue; entries.push({ id, @@ -55,24 +114,44 @@ export function parseCatalogEntries(payload: unknown): CatalogEntry[] { description: asString(wire.directory?.summary) ?? '', channelUrl, parameterDefaults: parseParameterDefaults(wire.parameters?.defaults), + lastResult, }); } - return entries; + return { entries, starters: parseStarterIds(envelope.meta?.starters) }; } /** - * Preferred included-feed starters (empty Create URL / creation-disabled Notice). - * Keep in lockstep with `Html2rss::Web::Catalog::Merge::STARTER_FEED_IDS`. + * @deprecated Prefer {@link parseCatalog}; kept as entries-only adapter. */ -export const STARTER_FEED_IDS = ['fao.org/newsroom', 'ftc.gov/press-releases', 'icrc.org/news'] as const; +export function parseCatalogEntries(payload: unknown): CatalogEntry[] { + return parseCatalog(payload).entries; +} /** - * Picks up to three starter feeds by preferred id, else the first catalog rows. + * Maps server `meta.starters` to entries. Fallback ranks by last_result + * (ok → unknown → failing) when starter ids are missing or unmatched. */ -export function selectStarterFeeds(entries: readonly CatalogEntry[]): CatalogEntry[] { - const selected = STARTER_FEED_IDS.map((id) => entries.find((entry) => entry.id === id)).filter( - (entry): entry is CatalogEntry => Boolean(entry) +export function selectStarterFeeds( + entries: readonly CatalogEntry[], + starterIds: readonly string[] = [] +): CatalogEntry[] { + if (starterIds.length > 0) { + const selected = starterIds + .map((id) => entries.find((entry) => entry.id === id)) + .filter((entry): entry is CatalogEntry => Boolean(entry)); + if (selected.length > 0) return selected; + } + + const preferred = entries.filter((entry) => !isFailingState(entry.lastResult.state)); + const pool = preferred.length > 0 ? preferred : entries; + return ( + [...pool] + // eslint-disable-next-line unicorn/no-array-sort -- TS lib predates Array#toSorted + .sort((a, b) => { + const rank = starterRank(a) - starterRank(b); + return rank === 0 ? a.id.localeCompare(b.id) : rank; + }) + .slice(0, STARTER_LIMIT) ); - return selected.length > 0 ? selected : entries.slice(0, 3); } diff --git a/frontend/src/catalog/types.ts b/frontend/src/catalog/types.ts index 944c77e1..de9f01a4 100644 --- a/frontend/src/catalog/types.ts +++ b/frontend/src/catalog/types.ts @@ -1,3 +1,13 @@ +/** Closed set mirroring RenderResult status plus never-scraped. */ +export type LastResultState = 'ok' | 'empty' | 'error' | 'unknown'; + +/** Wire/domain last-known scrape projection for a catalog row. */ +export interface LastResult { + state: LastResultState; + code?: string; + at?: string; +} + /** Domain catalog entry used for find and starter selection. */ export interface CatalogEntry { id: string; @@ -7,4 +17,13 @@ export interface CatalogEntry { channelUrl: string; /** String defaults from wire `parameters.defaults` (empty when none). */ parameterDefaults: Readonly>; + lastResult: LastResult; +} + +/** Parsed catalog API envelope (catalog_version 2 only). */ +export interface CatalogSnapshot { + entries: CatalogEntry[]; + starters: readonly string[]; } + +export const UNKNOWN_LAST_RESULT: LastResult = { state: 'unknown' }; diff --git a/frontend/src/catalog/useCatalogEntries.ts b/frontend/src/catalog/useCatalogEntries.ts index c9c6f471..0f84b236 100644 --- a/frontend/src/catalog/useCatalogEntries.ts +++ b/frontend/src/catalog/useCatalogEntries.ts @@ -1,18 +1,25 @@ import { useEffect, useState } from 'preact/hooks'; import type { ApiMetadataRecord } from '../api/contracts'; -import { parseCatalogEntries } from './parseCatalog'; +import { parseCatalog } from './parseCatalog'; import type { CatalogEntry } from './types'; +export type CatalogHookState = { + entries: CatalogEntry[]; + starters: readonly string[]; +}; + +const EMPTY: CatalogHookState = { entries: [], starters: [] }; + /** - * Loads catalog entries when the instance catalog is enabled. + * Loads catalog entries and meta.starters when the instance catalog is enabled. */ -export function useCatalogEntries(metadata?: ApiMetadataRecord): CatalogEntry[] { - const [entries, setEntries] = useState([]); +export function useCatalogEntries(metadata?: ApiMetadataRecord): CatalogHookState { + const [snapshot, setSnapshot] = useState(EMPTY); const catalog = metadata?.instance.catalog; useEffect(() => { if (!catalog?.enabled || !catalog.url) { - setEntries([]); + setSnapshot(EMPTY); return; } @@ -22,15 +29,16 @@ export function useCatalogEntries(metadata?: ApiMetadataRecord): CatalogEntry[] try { const response = await fetch(catalog.url, { headers: { Accept: 'application/json' } }); if (!response.ok) { - if (!isCancelled) setEntries([]); + if (!isCancelled) setSnapshot(EMPTY); return; } const payload: unknown = await response.json(); if (isCancelled) return; - setEntries(parseCatalogEntries(payload)); + const parsed = parseCatalog(payload); + setSnapshot({ entries: parsed.entries, starters: parsed.starters }); } catch { - if (!isCancelled) setEntries([]); + if (!isCancelled) setSnapshot(EMPTY); } }; @@ -40,5 +48,5 @@ export function useCatalogEntries(metadata?: ApiMetadataRecord): CatalogEntry[] }; }, [catalog?.enabled, catalog?.url]); - return entries; + return snapshot; } diff --git a/frontend/src/session/useSession.ts b/frontend/src/session/useSession.ts index d2e5b078..3f0f2628 100644 --- a/frontend/src/session/useSession.ts +++ b/frontend/src/session/useSession.ts @@ -28,8 +28,12 @@ export function useSession() { const isLoading = tokenLoading || metadataLoading; const feedCreation = metadata?.instance.feed_creation ?? DEFAULT_FEED_CREATION; const feedCreationEnabled = feedCreation.enabled; - const catalogEntries = useCatalogEntries(metadata); - const featuredFeeds: CatalogEntry[] = useMemo(() => selectStarterFeeds(catalogEntries), [catalogEntries]); + const catalog = useCatalogEntries(metadata); + const catalogEntries = catalog.entries; + const featuredFeeds: CatalogEntry[] = useMemo( + () => selectStarterFeeds(catalogEntries, catalog.starters), + [catalogEntries, catalog.starters] + ); const mayCreate = (accessToken?: string): MayCreateResult => { if (!feedCreation.enabled) return 'disabled'; From 7f0aa8f8aa20264de5002d827bc735ed7a761481 Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sat, 29 Aug 2026 13:49:17 +0200 Subject: [PATCH 3/4] fix(catalog): widen last_result OpenAPI code/at types Seed warm last_result rows in the configs OpenAPI example so code/at infer as string|null, and cover empty/error LastResults recording. --- frontend/src/api/generated/types.gen.ts | 4 +-- public/openapi.yaml | 8 +++-- spec/html2rss/web/api/v1_spec.rb | 43 +++++++++++++++++++++++++ spec/html2rss/web/feeds/service_spec.rb | 15 +++++++++ 4 files changed, 66 insertions(+), 4 deletions(-) diff --git a/frontend/src/api/generated/types.gen.ts b/frontend/src/api/generated/types.gen.ts index da532de8..c00cb582 100644 --- a/frontend/src/api/generated/types.gen.ts +++ b/frontend/src/api/generated/types.gen.ts @@ -76,8 +76,8 @@ export type GetConfigCatalogResponses = { }; id: string; last_result: { - at: null; - code: null; + at: string | null; + code: string | null; state: string; }; parameters: { diff --git a/public/openapi.yaml b/public/openapi.yaml index 928a8b58..f63e5c55 100644 --- a/public/openapi.yaml +++ b/public/openapi.yaml @@ -132,9 +132,13 @@ paths: last_result: properties: at: - type: 'null' + type: + - string + - 'null' code: - type: 'null' + type: + - string + - 'null' state: type: string required: diff --git a/spec/html2rss/web/api/v1_spec.rb b/spec/html2rss/web/api/v1_spec.rb index 4a1662a7..5de0a749 100644 --- a/spec/html2rss/web/api/v1_spec.rb +++ b/spec/html2rss/web/api/v1_spec.rb @@ -213,6 +213,35 @@ def relative_feed_link_header(token) tags: ['Catalog'], security: [{}] } do + # Seed warm last_result rows so OpenAPI infers string|null for code/at + # (cold-only responses freeze both fields to null). + before do + Html2rss::Web::Feeds::LastResults.clear! + Html2rss::Web::Feeds::LastResults.record( + 'fao.org/newsroom', + Html2rss::Web::Feeds::Contracts::RenderResult.new( + status: :ok, + payload: nil, + ttl_seconds: 600, + cache_key: 'feed_result:catalog-openapi' + ), + clock: -> { Time.utc(2026, 8, 29, 8) } + ) + Html2rss::Web::Feeds::LastResults.record( + 'ftc.gov/press-releases', + Html2rss::Web::Feeds::Contracts::RenderResult.new( + status: :empty, + decision: Html2rss::Web::ErrorClassifier::EXTRACTION_EMPTY, + payload: nil, + ttl_seconds: 600, + cache_key: 'feed_result:catalog-openapi-empty' + ), + clock: -> { Time.utc(2026, 8, 29, 9) } + ) + end + + after { Html2rss::Web::Feeds::LastResults.clear! } + it 'returns the merged catalog with CORS headers', :aggregate_failures do get '/api/v1/configs' @@ -229,6 +258,20 @@ def relative_feed_link_header(token) expect(json.dig('data', 'configs').first.fetch('last_result')).to include('state', 'code', 'at') end + it 'exposes warm last_result and demotes empty from starters', :aggregate_failures, openapi: false do + get '/api/v1/configs' + + json = expect_success_response(last_response) + expect(json.dig('meta', 'starters')).not_to include('ftc.gov/press-releases') + by_id = json.dig('data', 'configs').to_h { |row| [row.fetch('id'), row] } + expect(by_id.fetch('fao.org/newsroom').fetch('last_result')).to include( + 'state' => 'ok', 'code' => nil, 'at' => '2026-08-29T08:00:00Z' + ) + expect(by_id.fetch('ftc.gov/press-releases').fetch('last_result')).to include( + 'state' => 'empty', 'code' => 'EXTRACTION_EMPTY', 'at' => '2026-08-29T09:00:00Z' + ) + end + it 'returns 404 when the catalog is disabled', :aggregate_failures do ClimateControl.modify(CONFIG_CATALOG_ENABLED: 'false') do get '/api/v1/configs' diff --git a/spec/html2rss/web/feeds/service_spec.rb b/spec/html2rss/web/feeds/service_spec.rb index bd6aaae3..d1fabacc 100644 --- a/spec/html2rss/web/feeds/service_spec.rb +++ b/spec/html2rss/web/feeds/service_spec.rb @@ -110,6 +110,15 @@ allow(Html2rss).to receive(:feed_result).with(resolved_source.generator_input).and_return(feed_result) end + it 'records empty last_result for directory-defaults scrapes' do + described_class.call(resolved_source) + + expect(Html2rss::Web::Feeds::LastResults['example.com/articles']).to have_attributes( + state: :empty, + code: 'EXTRACTION_EMPTY' + ) + end + it 'marks the result as empty' do expect(result.status).to eq(:empty) end @@ -141,6 +150,12 @@ allow(Html2rss).to receive(:feed_result).with(resolved_source.generator_input).and_raise(StandardError, 'boom') end + it 'records error last_result even though errors are not Cache-retained' do + described_class.call(resolved_source) + + expect(Html2rss::Web::Feeds::LastResults['example.com/articles'].state).to eq(:error) + end + it 'marks the result as an error' do expect(result.status).to eq(:error) end From 542a60ab6f872d4a3306d86e0c15996dd095d7bd Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sat, 29 Aug 2026 14:33:50 +0200 Subject: [PATCH 4/4] fix(openapi): pin OAS 3.0.3 and define Catalog tag Spectral validates OAS 3.0 schemas; emit nullable: true instead of 3.1+ type unions, and register the Catalog operation tag globally. --- .spectral.yaml | 2 - public/openapi.yaml | 84 +++++++++++++++++------------------------ spec/support/openapi.rb | 4 ++ 3 files changed, 39 insertions(+), 51 deletions(-) diff --git a/.spectral.yaml b/.spectral.yaml index 050d3c18..df815227 100644 --- a/.spectral.yaml +++ b/.spectral.yaml @@ -1,3 +1 @@ extends: spectral:oas -rules: - oas3-schema: warn # TODO: remove line. demoted to warn as version 3.2.0 causes lint failures diff --git a/public/openapi.yaml b/public/openapi.yaml index f63e5c55..94bad611 100644 --- a/public/openapi.yaml +++ b/public/openapi.yaml @@ -1,5 +1,5 @@ --- -openapi: 3.2.0 +openapi: 3.0.3 info: title: html2rss-web API version: 1.10.2 @@ -132,13 +132,11 @@ paths: last_result: properties: at: - type: - - string - - 'null' + nullable: true + type: string code: - type: - - string - - 'null' + nullable: true + type: string state: type: string required: @@ -151,99 +149,85 @@ paths: defaults: properties: blog: - type: - - string - - 'null' + nullable: true + type: string id: - type: - - string - - 'null' + nullable: true + type: string region: - type: - - string - - 'null' + nullable: true + type: string repository: - type: - - string - - 'null' + nullable: true + type: string section: - type: - - string - - 'null' + nullable: true + type: string user_id: - type: - - string - - 'null' + nullable: true + type: string username: - type: - - string - - 'null' + nullable: true + type: string type: object schema: properties: blog: + nullable: true properties: type: type: string required: - type - type: - - object - - 'null' + type: object id: + nullable: true properties: type: type: string required: - type - type: - - object - - 'null' + type: object region: + nullable: true properties: type: type: string required: - type - type: - - object - - 'null' + type: object repository: + nullable: true properties: type: type: string required: - type - type: - - object - - 'null' + type: object section: + nullable: true properties: type: type: string required: - type - type: - - object - - 'null' + type: object user_id: + nullable: true properties: type: type: string required: - type - type: - - object - - 'null' + type: object username: + nullable: true properties: type: type: string required: - type - type: - - object - - 'null' + type: object type: object required: - schema @@ -866,6 +850,8 @@ components: tags: - name: Root description: API metadata and service-level information. +- name: Catalog + description: Public feed-directory catalog metadata. - name: Health description: Health and readiness endpoints. - name: Strategies diff --git a/spec/support/openapi.rb b/spec/support/openapi.rb index 30ea6e7e..0de1bc2e 100644 --- a/spec/support/openapi.rb +++ b/spec/support/openapi.rb @@ -7,6 +7,9 @@ RSpec::OpenAPI.path = 'public/openapi.yaml' RSpec::OpenAPI.title = 'html2rss-web API' RSpec::OpenAPI.application_version = Html2rss::Web::VERSION + # Spectral `oas3-schema` validates OAS 3.0; pin so nullables use `nullable: true` + # instead of OAS 3.1+ `type: [..., null]` unions. + RSpec::OpenAPI.openapi_version = '3.0.3' RSpec::OpenAPI.enable_example = false RSpec::OpenAPI.enable_example_summary = false RSpec::OpenAPI.example_types = [:request] @@ -223,6 +226,7 @@ tags = [ { 'name' => 'Root', 'description' => 'API metadata and service-level information.' }, + { 'name' => 'Catalog', 'description' => 'Public feed-directory catalog metadata.' }, { 'name' => 'Health', 'description' => 'Health and readiness endpoints.' }, { 'name' => 'Strategies', 'description' => 'Feed extraction strategy discovery.' }, { 'name' => 'Feeds', 'description' => 'Feed creation and feed rendering operations.' }