Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions .spectral.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1 @@
extends: spectral:oas
rules:
oas3-schema: warn # TODO: remove line. demoted to warn as version 3.2.0 causes lint failures
6 changes: 4 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
10 changes: 7 additions & 3 deletions app/web/api/v1/configs.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ module V1
##
# Public config catalog endpoint for feed directory clients.
module Configs
CATALOG_VERSION = 1
CATALOG_VERSION = 2

class << self
##
Expand Down Expand Up @@ -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
Expand Down
55 changes: 55 additions & 0 deletions app/web/catalog/entry.rb
Original file line number Diff line number Diff line change
@@ -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
153 changes: 76 additions & 77 deletions app/web/catalog/merge.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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<Html2rss::Web::Catalog::Entry>]
def call
(embedded_entries + local_entries).sort_by(&:id)
end

module_function
private

##
# @return [Array<Hash{Symbol => 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<Html2rss::Web::Catalog::Entry>]
def embedded_entries
Html2rss::Configs::Catalog.entries.map { |entry| join_last_result(entry.to_h) }
end

##
# @return [Array<Hash{Symbol => 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<Html2rss::Web::Catalog::Entry>]
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<Hash{Symbol => 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
Expand Down
29 changes: 29 additions & 0 deletions app/web/catalog/parameter_defaults.rb
Original file line number Diff line number Diff line change
@@ -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
64 changes: 64 additions & 0 deletions app/web/catalog/starters.rb
Original file line number Diff line number Diff line change
@@ -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<Html2rss::Web::Catalog::Entry>]
# @param limit [Integer]
# @return [Array<String>]
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<Html2rss::Web::Catalog::Entry>]
# @param state [Symbol]
# @return [Array<Html2rss::Web::Catalog::Entry>]
def select_states(entries, state)
entries.select { it.last_result.state == state }
end

# Stable by cold-seed index then id.
#
# @param entries [Array<Html2rss::Web::Catalog::Entry>]
# @return [Array<Html2rss::Web::Catalog::Entry>]
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
16 changes: 15 additions & 1 deletion app/web/feeds/contracts.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading