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
18 changes: 18 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,21 @@ See [docs/design-system.md](docs/design-system.md) for visual rules.
- **Security first:** No leaking secrets or insecure patterns. See [Security & Safety Rules](docs/README.md#security--safety-rules).
- **YARD docs:** Strict for public Ruby methods in `app/`. Every public method must have a YARD docstring with typed `@param` and `@return`. See [Architectural Constraints](docs/README.md#architectural-constraints).
- **No host execution:** All commands MUST run inside the Dev Container via `make` or `bundle exec`.

## Config catalog API

Public feed-directory metadata for embedded and local configs.

| Item | Detail |
| --- | --- |
| Endpoint | `GET /api/v1/configs` |
| Flag | `CONFIG_CATALOG_ENABLED` (default `true`; set `false` to disable) |
| 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) | `Catalog::Merge.starter_entries` — used by frontend when feed creation is disabled |
| 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` |

After handler or envelope changes: `make openapi` and `make ci-ready`.
5 changes: 3 additions & 2 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ source 'https://rubygems.org'

git_source(:github) { |repo_name| "https://github.com/#{repo_name}" }

gem 'html2rss', '~> 0.26'
gem 'html2rss', github: 'html2rss/html2rss', branch: 'feat/feed-directory-catalog-schema'
# gem 'html2rss', '~> 0.26'
# gem 'html2rss', github: 'html2rss/html2rss', branch: 'master'
gem 'html2rss-configs', github: 'html2rss/html2rss-configs'
gem 'html2rss-configs', github: 'html2rss/html2rss-configs', branch: 'feat/feed-directory-catalog'

# Use these instead of the two above (uncomment them) when developing locally:
# gem 'html2rss', path: '../html2rss'
Expand Down
55 changes: 31 additions & 24 deletions Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,6 +1,34 @@
GIT
remote: https://github.com/html2rss/html2rss
revision: 7f1485b36ed6f298377dbf83daff618ac4cd1824
branch: feat/feed-directory-catalog-schema
specs:
html2rss (0.27.0)
addressable (~> 2.7)
brotli
dry-validation
faraday (> 2.0.1, < 3.0)
faraday-follow_redirects
faraday-gzip (~> 3)
kramdown
mcp (~> 1.2)
mime-types (> 3.0)
nokogiri (>= 1.10, < 2.0)
rack (~> 3.0)
rackup (~> 2.0)
regexp_parser
reverse_markdown (~> 3.0)
rss
sanitize
thor
tzinfo
webrick (~> 1.9)
zeitwerk

GIT
remote: https://github.com/html2rss/html2rss-configs
revision: 90f43228fb93f59c7c7c07c477b5f68a5967519e
revision: 2d73ddf6be3fd8b0562d5216aea99eab68a978f8
branch: feat/feed-directory-catalog
specs:
html2rss-configs (0.2.0)
html2rss
Expand Down Expand Up @@ -103,27 +131,6 @@ GEM
net-http (~> 0.5)
hana (1.3.7)
hashdiff (1.2.1)
html2rss (0.27.0)
addressable (~> 2.7)
brotli
dry-validation
faraday (> 2.0.1, < 3.0)
faraday-follow_redirects
faraday-gzip (~> 3)
kramdown
mcp (~> 1.2)
mime-types (> 3.0)
nokogiri (>= 1.10, < 2.0)
rack (~> 3.0)
rackup (~> 2.0)
regexp_parser
reverse_markdown (~> 3.0)
rss
sanitize
thor
tzinfo
webrick (~> 1.9)
zeitwerk
i18n (1.15.2)
concurrent-ruby (~> 1.0)
io-console (0.9.2)
Expand Down Expand Up @@ -319,7 +326,7 @@ PLATFORMS
DEPENDENCIES
base64
climate_control
html2rss (~> 0.26)
html2rss!
html2rss-configs!
irb
puma
Expand Down Expand Up @@ -377,7 +384,7 @@ CHECKSUMS
faraday-net_http (3.4.4) sha256=0e78af151747ed1b00f33e25973b4bc220d7f16c00c39676817c8b12331eb588
hana (1.3.7) sha256=5425db42d651fea08859811c29d20446f16af196308162894db208cac5ce9b0d
hashdiff (1.2.1) sha256=9c079dbc513dfc8833ab59c0c2d8f230fa28499cc5efb4b8dd276cf931457cd1
html2rss (0.27.0) sha256=a3b830bb60416627e7a44a39b1cf5aa278a64032750d4a0dbc75482de7535ff1
html2rss (0.27.0)
html2rss-configs (0.2.0)
i18n (1.15.2) sha256=00f9eb62412fe593b2a65a97daa75300d37abb8f7202ec748e94b6d46a9dd1b5
io-console (0.9.2) sha256=efa74f891dd03c0939a931dfc6e74c2813d904763d456ea9762b0525e748db08
Expand Down
62 changes: 62 additions & 0 deletions app/web/api/v1/configs.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# frozen_string_literal: true

module Html2rss
module Web
module Api
module V1
##
# Public config catalog endpoint for feed directory clients.
module Configs
CATALOG_VERSION = 1

class << self
##
# @param _router [Roda::RodaRequest]
# @return [Hash{Symbol => Object}]
def index(_router)
entries, duration_ms = build_entries
emit_success(entries.size, duration_ms)
success_payload(entries)
rescue Html2rss::Configs::Catalog::MissingDirectoryTitle => error
emit_failure(error)
raise
end

private

def build_entries
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
entries = Html2rss::Web::Catalog::Merge.call
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round
[entries, duration_ms]
end

def emit_success(count, duration_ms)
Observability.emit(
event_name: 'catalog.build',
outcome: 'success',
details: { count:, duration_ms: }
)
end

def emit_failure(error)
Observability.emit(
event_name: 'catalog.build',
outcome: 'failure',
level: :warn,
details: { reason: error.message }
)
end

def success_payload(entries)
Response.success(
data: { configs: entries },
meta: { total: entries.size, catalog_version: CATALOG_VERSION }
)
end
end
end
end
end
end
end
27 changes: 6 additions & 21 deletions app/web/api/v1/root_metadata.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,6 @@ module V1
##
# Builds the public metadata payload for the API root endpoint.
module RootMetadata
FEATURED_FEEDS = [
{
path: '/microsoft.com/azure-products.rss',
title: 'Azure product updates',
description: 'Follow Microsoft Azure product announcements from your own instance.'
},
{
path: '/phys.org/weekly.rss',
title: 'Top science news of the week',
description: 'Try a high-signal feed with stable weekly headlines from the built-in config set.'
},
{
path: '/softwareleadweekly.com/issues.rss',
title: 'Software Lead Weekly issues',
description: 'Follow a long-running newsletter archive from the embedded config catalog.'
}
].freeze

class << self
# @param router [Roda::RodaRequest]
# @return [Hash{Symbol=>Object}]
Expand All @@ -41,15 +23,18 @@ def build(router)

private

# @param _router [Roda::RodaRequest]
# @param router [Roda::RodaRequest]
# @return [Hash{Symbol=>Object}]
def instance_payload(_router)
def instance_payload(router)
{
feed_creation: {
enabled: Flags.auto_source_enabled?,
access_token_required: Flags.auto_source_enabled?
},
featured_feeds: FEATURED_FEEDS
catalog: {
enabled: Flags.config_catalog_enabled?,
url: "#{router.base_url}/api/v1/configs"
}
}
end
end
Expand Down
101 changes: 101 additions & 0 deletions app/web/catalog/merge.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# frozen_string_literal: true

require 'html2rss/configs'

module Html2rss
module Web
##
# Merges embedded catalog entries with local feed configs for the public catalog API.
module Catalog
module Merge
STARTER_FEED_IDS = %w[
microsoft.com/azure-products
phys.org/weekly
softwareleadweekly.com/issues
].freeze

module_function

##
# @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<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<Hash{Symbol => Object}>]
def local_entries
LocalConfig.feeds.filter_map do |feed_name, feed_config|
build_local_entry(feed_name, feed_config)
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?

id = feed_name.to_s
channel = feed_config[:channel] || {}

local_entry(id, directory, title, channel)
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 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
end
14 changes: 13 additions & 1 deletion app/web/config/flags.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ module Flags # rubocop:disable Metrics/ModuleLength
default: -> { development_or_test? },
validator: nil
),
config_catalog_enabled: Definition.new(
name: :config_catalog_enabled,
env_key: 'CONFIG_CATALOG_ENABLED',
type: :boolean,
default: true,
validator: nil
),
async_feed_refresh_enabled: Definition.new(
name: :async_feed_refresh_enabled,
env_key: 'ASYNC_FEED_REFRESH_ENABLED',
Expand Down Expand Up @@ -75,7 +82,7 @@ module Flags # rubocop:disable Metrics/ModuleLength
)
}.freeze
MANAGED_ENV_PREFIXES = %w[
AUTO_SOURCE_ ASYNC_FEED_REFRESH_ FEEDS_CACHE_ RATE_LIMIT_ RETRY_AFTER_
AUTO_SOURCE_ ASYNC_FEED_REFRESH_ CONFIG_CATALOG_ FEEDS_CACHE_ RATE_LIMIT_ RETRY_AFTER_
].freeze

class << self
Expand Down Expand Up @@ -104,6 +111,11 @@ def feeds_cache_max_size
fetch(:feeds_cache_max_size)
end

# @return [Boolean]
def config_catalog_enabled?
fetch(:config_catalog_enabled)
end

# @return [Boolean]
def auto_source_enabled?
fetch(:auto_source_enabled)
Expand Down
1 change: 1 addition & 0 deletions app/web/routes/api_v1.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ def call(router)

HealthRoutes.call(router)
FeedRoutes.call(router)
ConfigsRoutes.call(router)
MetadataRoutes.call(router)

raise NotFoundError
Expand Down
Loading
Loading