From 3942f56ac4f1a595d8c27b363f29c7aa1e45d240 Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Wed, 19 Aug 2026 18:16:08 +0200 Subject: [PATCH 1/4] feat(api): expose config catalog at GET /api/v1/configs --- AGENTS.md | 18 ++ Gemfile | 5 +- Gemfile.lock | 55 ++--- app/web/api/v1/configs.rb | 45 ++++ app/web/api/v1/root_metadata.rb | 27 +-- app/web/catalog/merge.rb | 75 +++++++ app/web/config/flags.rb | 14 +- app/web/routes/api_v1.rb | 1 + app/web/routes/api_v1/configs_routes.rb | 52 +++++ docs/README.md | 1 + frontend/e2e/smoke.spec.ts | 6 +- frontend/src/__tests__/App.test.tsx | 40 ++-- frontend/src/__tests__/mocks/server.ts | 5 +- frontend/src/__tests__/useSession.test.ts | 4 +- frontend/src/api/contracts.ts | 9 +- frontend/src/hooks/useStarterFeeds.ts | 78 +++++++ frontend/src/session/useSession.ts | 3 +- public/openapi.yaml | 244 ++++++++++++++++++++-- spec/html2rss/web/api/v1_spec.rb | 51 ++++- 19 files changed, 634 insertions(+), 99 deletions(-) create mode 100644 app/web/api/v1/configs.rb create mode 100644 app/web/catalog/merge.rb create mode 100644 app/web/routes/api_v1/configs_routes.rb create mode 100644 frontend/src/hooks/useStarterFeeds.ts diff --git a/AGENTS.md b/AGENTS.md index f48493777..e0e995eed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`. diff --git a/Gemfile b/Gemfile index 4991a168a..23ae07f36 100644 --- a/Gemfile +++ b/Gemfile @@ -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' diff --git a/Gemfile.lock b/Gemfile.lock index 92f6fa6b7..be56bb48f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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 @@ -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) @@ -319,7 +326,7 @@ PLATFORMS DEPENDENCIES base64 climate_control - html2rss (~> 0.26) + html2rss! html2rss-configs! irb puma @@ -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 diff --git a/app/web/api/v1/configs.rb b/app/web/api/v1/configs.rb new file mode 100644 index 000000000..0b3cb826a --- /dev/null +++ b/app/web/api/v1/configs.rb @@ -0,0 +1,45 @@ +# 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) + started = Process.clock_gettime(Process::CLOCK_MONOTONIC) + entries = Html2rss::Web::Catalog::Merge.call + duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round + + Observability.emit( + event_name: 'catalog.build', + outcome: 'success', + details: { count: entries.size, duration_ms: } + ) + + Response.success( + data: { configs: entries }, + meta: { total: entries.size, catalog_version: CATALOG_VERSION } + ) + rescue Html2rss::Configs::Catalog::MissingDirectoryTitle => error + Observability.emit( + event_name: 'catalog.build', + outcome: 'failure', + level: :warn, + details: { reason: error.message } + ) + raise + end + end + end + end + end + end +end diff --git a/app/web/api/v1/root_metadata.rb b/app/web/api/v1/root_metadata.rb index 0caf8b8b6..562f5a86b 100644 --- a/app/web/api/v1/root_metadata.rb +++ b/app/web/api/v1/root_metadata.rb @@ -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}] @@ -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 diff --git a/app/web/catalog/merge.rb b/app/web/catalog/merge.rb new file mode 100644 index 000000000..f59c4edac --- /dev/null +++ b/app/web/catalog/merge.rb @@ -0,0 +1,75 @@ +# 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 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 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 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] || {} + + { + id:, + path: "/#{id}.rss", + source: 'local', + directory: { + title: title.to_s, + summary: directory[:summary], + topics: Array(directory[:topics]) + }.compact, + channel: { + url: channel.fetch(:url), + language: channel[:language], + title: channel[:title] || title.to_s + }.compact, + parameters: { schema: {}, defaults: {} } + } + end + end + end + end +end diff --git a/app/web/config/flags.rb b/app/web/config/flags.rb index cc825cad1..dc80ef642 100644 --- a/app/web/config/flags.rb +++ b/app/web/config/flags.rb @@ -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', @@ -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 @@ -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) diff --git a/app/web/routes/api_v1.rb b/app/web/routes/api_v1.rb index 44f7ed487..15764ae48 100644 --- a/app/web/routes/api_v1.rb +++ b/app/web/routes/api_v1.rb @@ -21,6 +21,7 @@ def call(router) HealthRoutes.call(router) FeedRoutes.call(router) + ConfigsRoutes.call(router) MetadataRoutes.call(router) raise NotFoundError diff --git a/app/web/routes/api_v1/configs_routes.rb b/app/web/routes/api_v1/configs_routes.rb new file mode 100644 index 000000000..e45d07dde --- /dev/null +++ b/app/web/routes/api_v1/configs_routes.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Html2rss + module Web + module Routes + module ApiV1 + ## + # Mounts the public config catalog endpoint with route-scoped CORS. + module ConfigsRoutes + CORS_HEADERS = { + 'Access-Control-Allow-Origin' => '*', + 'Access-Control-Allow-Methods' => 'GET, OPTIONS', + 'Access-Control-Allow-Headers' => 'Accept, Content-Type' + }.freeze + + class << self + ## + # @param router [Roda::RodaRequest] + # @return [void] + def call(router) + router.on 'configs' do + apply_cors!(router) + + router.options do + router.response.status = 204 + '' + end + + router.get do + unless Flags.config_catalog_enabled? + router.response.status = 404 + next JSON.generate(error: 'catalog_disabled') + end + + JSON.generate(Api::V1::Configs.index(router)) + end + end + end + + private + + # @param router [Roda::RodaRequest] + # @return [void] + def apply_cors!(router) + CORS_HEADERS.each { |header, value| router.response[header] = value } + end + end + end + end + end + end +end diff --git a/docs/README.md b/docs/README.md index c8782b3ec..fd72a30ce 100644 --- a/docs/README.md +++ b/docs/README.md @@ -26,6 +26,7 @@ Welcome! This is the canonical source of truth for contributing to `html2rss-web - **Runtime behavior**: Application code plus tests. - **HTTP contract**: Request specs plus generated OpenAPI. +- **Config catalog API**: `GET /api/v1/configs` — embedded data from `Html2rss::Configs::Catalog`, merged with local `feeds.yml` entries that include `directory.title`. Disabled when `CONFIG_CATALOG_ENABLED=false` (`404`, `catalog_disabled`). CORS is enabled on this route only. - **This file**: Contributor conventions and current project rules. --- diff --git a/frontend/e2e/smoke.spec.ts b/frontend/e2e/smoke.spec.ts index c4776b1f1..b4a8ae3d7 100644 --- a/frontend/e2e/smoke.spec.ts +++ b/frontend/e2e/smoke.spec.ts @@ -20,7 +20,7 @@ test.describe('frontend smoke', () => { enabled: true, access_token_required: true, }, - featured_feeds: [], + catalog: { enabled: true, url: '/api/v1/configs' }, }, }, }), @@ -68,7 +68,7 @@ test.describe('frontend smoke', () => { enabled: true, access_token_required: false, }, - featured_feeds: [], + catalog: { enabled: true, url: '/api/v1/configs' }, }, }, }), @@ -135,7 +135,7 @@ test.describe('frontend smoke', () => { enabled: true, access_token_required: true, }, - featured_feeds: [], + catalog: { enabled: true, url: '/api/v1/configs' }, }, }, }), diff --git a/frontend/src/__tests__/App.test.tsx b/frontend/src/__tests__/App.test.tsx index 4a2ea07d0..cf4797693 100644 --- a/frontend/src/__tests__/App.test.tsx +++ b/frontend/src/__tests__/App.test.tsx @@ -85,7 +85,7 @@ describe('App', () => { enabled: true, access_token_required: true, }, - featured_feeds: [], + catalog: { enabled: true, url: '/api/v1/configs' }, }, }, isLoading: false, @@ -125,7 +125,7 @@ describe('App', () => { enabled: true, access_token_required: false, }, - featured_feeds: [], + catalog: { enabled: true, url: '/api/v1/configs' }, }, }, isLoading: false, @@ -339,7 +339,26 @@ describe('App', () => { expect(mockCreateFeed).not.toHaveBeenCalled(); }); - it('promotes included feeds when feed creation is disabled', () => { + it('promotes included feeds when feed creation is disabled', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch'); + fetchMock.mockResolvedValueOnce( + Response.json({ + success: true, + data: { + configs: [ + { + id: 'microsoft.com/azure-products', + path: '/microsoft.com/azure-products.rss', + directory: { + title: 'Azure product updates', + summary: 'Follow Microsoft Azure product announcements from your own instance.', + }, + }, + ], + }, + }) + ); + mockUseApiMetadata.mockReturnValue({ metadata: { api: { @@ -352,13 +371,7 @@ describe('App', () => { enabled: false, access_token_required: false, }, - featured_feeds: [ - { - path: '/microsoft.com/azure-products.rss', - title: 'Azure product updates', - description: 'Follow Microsoft Azure product announcements from your own instance.', - }, - ], + catalog: { enabled: true, url: '/api/v1/configs' }, }, }, isLoading: false, @@ -367,12 +380,15 @@ describe('App', () => { render(); - expect(screen.getByText(COPY.includedFeedsTitle)).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByText(COPY.includedFeedsTitle)).toBeInTheDocument(); + }); expect(screen.getByRole('link', { name: 'Azure product updates' })).toHaveAttribute( 'href', '/microsoft.com/azure-products.rss' ); expect(screen.getByText(COPY.creationDisabled)).toBeInTheDocument(); + fetchMock.mockRestore(); }); it('renders the result panel when a feed is available', async () => { @@ -969,7 +985,7 @@ describe('App', () => { enabled: true, access_token_required: true, }, - featured_feeds: [], + catalog: { enabled: true, url: '/api/v1/configs' }, }, }, isLoading: false, diff --git a/frontend/src/__tests__/mocks/server.ts b/frontend/src/__tests__/mocks/server.ts index 3ba862e00..6a2c274d7 100644 --- a/frontend/src/__tests__/mocks/server.ts +++ b/frontend/src/__tests__/mocks/server.ts @@ -16,7 +16,10 @@ export const server = setupServer( enabled: true, access_token_required: true, }, - featured_feeds: [], + catalog: { + enabled: true, + url: '/api/v1/configs', + }, }, }, }); diff --git a/frontend/src/__tests__/useSession.test.ts b/frontend/src/__tests__/useSession.test.ts index 0ebf53c0d..1a6ef274f 100644 --- a/frontend/src/__tests__/useSession.test.ts +++ b/frontend/src/__tests__/useSession.test.ts @@ -8,7 +8,7 @@ const ACCESS_TOKEN_KEY = 'html2rss_access_token'; const mockMetadata = { instance: { feed_creation: { enabled: true, access_token_required: true }, - featured_feeds: [{ name: 'Test Feed', url: 'https://example.com' }], + catalog: { enabled: true, url: '/api/v1/configs' }, }, api: { openapi_url: '/openapi.yaml' }, }; @@ -45,7 +45,7 @@ describe('useSession', () => { expect(result.current.token).toBe('session-token'); expect(result.current.hasToken).toBe(true); - expect(result.current.featuredFeeds).toEqual(mockMetadata.instance.featured_feeds); + expect(result.current.featuredFeeds).toEqual([]); expect(result.current.metadataError).toBeUndefined(); expect(result.current.feedCreationEnabled).toBe(true); }); diff --git a/frontend/src/api/contracts.ts b/frontend/src/api/contracts.ts index f4bc954a8..76fffed96 100644 --- a/frontend/src/api/contracts.ts +++ b/frontend/src/api/contracts.ts @@ -75,10 +75,9 @@ export interface ApiMetadataRecord { enabled: boolean; access_token_required: boolean; }; - featured_feeds?: Array<{ - path: string; - title: string; - description: string; - }>; + catalog?: { + enabled: boolean; + url: string; + }; }; } diff --git a/frontend/src/hooks/useStarterFeeds.ts b/frontend/src/hooks/useStarterFeeds.ts new file mode 100644 index 000000000..af1bb640a --- /dev/null +++ b/frontend/src/hooks/useStarterFeeds.ts @@ -0,0 +1,78 @@ +import { useEffect, useState } from 'preact/hooks'; +import type { ApiMetadataRecord } from '../api/contracts'; + +export interface StarterFeed { + path: string; + title: string; + description: string; +} + +interface CatalogEnvelope { + success?: boolean; + data?: { + configs?: Array<{ + id: string; + path: string; + directory?: { + title?: string; + summary?: string; + }; + }>; + }; +} + +const STARTER_FEED_IDS = [ + 'microsoft.com/azure-products', + 'phys.org/weekly', + 'softwareleadweekly.com/issues', +]; + +/** + * Loads starter feeds from the public catalog when feed creation is disabled. + */ +export function useStarterFeeds(metadata?: ApiMetadataRecord, feedCreationEnabled = true) { + const [starterFeeds, setStarterFeeds] = useState([]); + const catalog = metadata?.instance.catalog; + + useEffect(() => { + if (feedCreationEnabled || !catalog?.enabled || !catalog.url) { + setStarterFeeds([]); + return; + } + + let cancelled = false; + + const load = async () => { + try { + const response = await fetch(catalog.url, { headers: { Accept: 'application/json' } }); + if (!response.ok) return; + + const payload = (await response.json()) as CatalogEnvelope; + const configs = payload.data?.configs ?? []; + const selected = STARTER_FEED_IDS.map((id) => configs.find((entry) => entry.id === id)).filter( + (entry): entry is NonNullable => Boolean(entry), + ); + const entries = selected.length > 0 ? selected : configs.slice(0, 3); + + if (cancelled) return; + + setStarterFeeds( + entries.map((entry) => ({ + path: entry.path, + title: entry.directory?.title ?? entry.id, + description: entry.directory?.summary ?? '', + })), + ); + } catch { + if (!cancelled) setStarterFeeds([]); + } + }; + + load(); + return () => { + cancelled = true; + }; + }, [catalog?.enabled, catalog?.url, feedCreationEnabled]); + + return starterFeeds; +} diff --git a/frontend/src/session/useSession.ts b/frontend/src/session/useSession.ts index e107b2482..8b82918c8 100644 --- a/frontend/src/session/useSession.ts +++ b/frontend/src/session/useSession.ts @@ -1,4 +1,5 @@ import { useApiMetadata } from '../hooks/useApiMetadata'; +import { useStarterFeeds } from '../hooks/useStarterFeeds'; import { useAccessToken } from './accessToken'; const DEFAULT_FEED_CREATION = { enabled: true, access_token_required: true }; @@ -23,9 +24,9 @@ export function useSession() { const { metadata, isLoading: metadataLoading, error: metadataError } = useApiMetadata(); const isLoading = tokenLoading || metadataLoading; - const featuredFeeds = metadata?.instance.featured_feeds ?? []; const feedCreation = metadata?.instance.feed_creation ?? DEFAULT_FEED_CREATION; const feedCreationEnabled = feedCreation.enabled; + const featuredFeeds = useStarterFeeds(metadata, feedCreationEnabled); const mayCreate = (accessToken?: string): MayCreateResult => { if (!feedCreation.enabled) return 'disabled'; diff --git a/public/openapi.yaml b/public/openapi.yaml index 82f19d10c..e38526d10 100644 --- a/public/openapi.yaml +++ b/public/openapi.yaml @@ -44,21 +44,16 @@ paths: type: object instance: properties: - featured_feeds: - items: - properties: - description: - type: string - path: - type: string - title: - type: string - required: - - path - - title - - description - type: object - type: array + catalog: + properties: + enabled: + type: boolean + url: + type: string + required: + - enabled + - url + type: object feed_creation: properties: access_token_required: @@ -71,7 +66,7 @@ paths: type: object required: - feed_creation - - featured_feeds + - catalog type: object required: - api @@ -83,12 +78,227 @@ paths: - success - data type: object - description: returns API information with trailing slash + description: returns catalog pointer metadata security: - {} summary: API metadata tags: - Root + "/configs": + get: + description: Config catalog + operationId: getConfigCatalog + responses: + '200': + content: + application/json: + schema: + properties: + data: + properties: + configs: + items: + properties: + channel: + properties: + language: + type: string + title: + type: string + url: + type: string + required: + - url + - language + - title + type: object + directory: + properties: + summary: + type: string + title: + type: string + topics: + items: + type: string + type: array + required: + - title + - summary + - topics + type: object + id: + type: string + parameters: + properties: + defaults: + properties: + blog: + type: + - string + - 'null' + id: + type: + - string + - 'null' + region: + type: + - string + - 'null' + repository: + type: + - string + - 'null' + section: + type: + - string + - 'null' + user_id: + type: + - string + - 'null' + username: + type: + - string + - 'null' + type: object + schema: + properties: + blog: + properties: + type: + type: string + required: + - type + type: + - object + - 'null' + id: + properties: + type: + type: string + required: + - type + type: + - object + - 'null' + region: + properties: + type: + type: string + required: + - type + type: + - object + - 'null' + repository: + properties: + type: + type: string + required: + - type + type: + - object + - 'null' + section: + properties: + type: + type: string + required: + - type + type: + - object + - 'null' + user_id: + properties: + type: + type: string + required: + - type + type: + - object + - 'null' + username: + properties: + type: + type: string + required: + - type + type: + - object + - 'null' + type: object + required: + - schema + - defaults + type: object + path: + type: string + source: + type: string + required: + - id + - path + - source + - directory + - channel + - parameters + type: object + type: array + required: + - configs + type: object + meta: + properties: + catalog_version: + type: integer + total: + type: integer + required: + - total + - catalog_version + type: object + success: + type: boolean + required: + - success + - data + - meta + type: object + description: returns the merged catalog with CORS headers + '404': + content: + application/json: + schema: + properties: + error: + type: string + required: + - error + type: object + description: returns 404 when the catalog is disabled + security: + - {} + summary: Config catalog + tags: + - Catalog + options: + description: Config catalog + operationId: getConfigCatalog + requestBody: + content: + application/x-www-form-urlencoded: + schema: + properties: {} + type: object + responses: + '204': + description: responds to preflight requests with CORS headers + security: + - {} + summary: Config catalog + tags: + - Catalog "/feeds": post: description: Create a feed diff --git a/spec/html2rss/web/api/v1_spec.rb b/spec/html2rss/web/api/v1_spec.rb index 639c23612..ab79fd107 100644 --- a/spec/html2rss/web/api/v1_spec.rb +++ b/spec/html2rss/web/api/v1_spec.rb @@ -126,14 +126,7 @@ def json_feed_headers_tuple end def expected_featured_feeds - [ - ['/microsoft.com/azure-products.rss', 'Azure product updates', - 'Follow Microsoft Azure product announcements from your own instance.'], - ['/phys.org/weekly.rss', 'Top science news of the week', - 'Try a high-signal feed with stable weekly headlines from the built-in config set.'], - ['/softwareleadweekly.com/issues.rss', 'Software Lead Weekly issues', - 'Follow a long-running newsletter archive from the embedded config catalog.'] - ].map { |path, title, description| { 'path' => path, 'title' => title, 'description' => description } } + [] end # @param token [String] @@ -192,12 +185,15 @@ def relative_feed_link_header(token) ) end - it 'returns featured included feeds for trial runs', :aggregate_failures do + it 'returns catalog pointer metadata', :aggregate_failures do get '/api/v1' expect(last_response.status).to eq(200) json = expect_success_response(last_response) - expect(json.dig('data', 'instance', 'featured_feeds')).to eq(expected_featured_feeds) + expect(json.dig('data', 'instance', 'catalog')).to eq( + 'enabled' => true, + 'url' => 'http://example.org/api/v1/configs' + ) end it 'returns API information with trailing slash', :aggregate_failures do @@ -211,6 +207,41 @@ def relative_feed_link_header(token) end end + describe 'GET /api/v1/configs', openapi: { + summary: 'Config catalog', + operation_id: 'getConfigCatalog', + tags: ['Catalog'], + security: [{}] + } do + it 'returns the merged catalog with CORS headers', :aggregate_failures do + get '/api/v1/configs' + + 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('data', 'configs')).to be_an(Array) + expect(json.dig('data', 'configs').first).to include('id', 'path', 'source', 'directory', 'channel', 'parameters') + end + + it 'returns 404 when the catalog is disabled', :aggregate_failures do + ClimateControl.modify(CONFIG_CATALOG_ENABLED: 'false') do + get '/api/v1/configs' + + expect(last_response.status).to eq(404) + expect(JSON.parse(last_response.body)).to eq('error' => 'catalog_disabled') + end + end + + it 'responds to preflight requests with CORS headers', :aggregate_failures do + options '/api/v1/configs' + + expect(last_response.status).to eq(204) + expect(last_response.headers['Access-Control-Allow-Origin']).to eq('*') + expect(last_response.headers['Access-Control-Allow-Methods']).to include('GET') + end + end + describe 'GET /api/v1/openapi.yaml', openapi: false do it 'redirects the versioned OpenAPI path to the public spec', :aggregate_failures do get '/api/v1/openapi.yaml' From 9b5be7ef4e2b6fb570801e030bec7638722612c6 Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Wed, 19 Aug 2026 18:24:19 +0200 Subject: [PATCH 2/4] fix(api): satisfy CI for catalog endpoint and OpenAPI client Extract catalog handler helpers for RuboCop, format useStarterFeeds, and commit regenerated OpenAPI client types for GET /configs. --- app/web/api/v1/configs.rb | 31 +++++-- frontend/src/api/generated/index.ts | 4 +- frontend/src/api/generated/sdk.gen.ts | 26 +++++- frontend/src/api/generated/types.gen.ts | 113 ++++++++++++++++++++++-- frontend/src/hooks/useStarterFeeds.ts | 10 +-- 5 files changed, 160 insertions(+), 24 deletions(-) diff --git a/app/web/api/v1/configs.rb b/app/web/api/v1/configs.rb index 0b3cb826a..900b9b9b9 100644 --- a/app/web/api/v1/configs.rb +++ b/app/web/api/v1/configs.rb @@ -14,28 +14,45 @@ 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: entries.size, duration_ms: } + details: { count:, duration_ms: } ) + end - Response.success( - data: { configs: entries }, - meta: { total: entries.size, catalog_version: CATALOG_VERSION } - ) - rescue Html2rss::Configs::Catalog::MissingDirectoryTitle => error + def emit_failure(error) Observability.emit( event_name: 'catalog.build', outcome: 'failure', level: :warn, details: { reason: error.message } ) - raise + end + + def success_payload(entries) + Response.success( + data: { configs: entries }, + meta: { total: entries.size, catalog_version: CATALOG_VERSION } + ) end end end diff --git a/frontend/src/api/generated/index.ts b/frontend/src/api/generated/index.ts index 2494ebbd4..9d88b35f2 100644 --- a/frontend/src/api/generated/index.ts +++ b/frontend/src/api/generated/index.ts @@ -1,4 +1,4 @@ // This file is auto-generated by @hey-api/openapi-ts -export { createFeed, getApiMetadata, getHealthStatus, getLivenessProbe, getReadinessProbe, listStrategies, type Options, renderFeedByToken } from './sdk.gen'; -export type { ClientOptions, CreateFeedData, CreateFeedError, CreateFeedErrors, CreateFeedResponse, CreateFeedResponses, GetApiMetadataData, GetApiMetadataResponse, GetApiMetadataResponses, GetHealthStatusData, GetHealthStatusError, GetHealthStatusErrors, GetHealthStatusResponse, GetHealthStatusResponses, GetLivenessProbeData, GetLivenessProbeResponse, GetLivenessProbeResponses, GetReadinessProbeData, GetReadinessProbeResponse, GetReadinessProbeResponses, ListStrategiesData, ListStrategiesResponse, ListStrategiesResponses, RenderFeedByTokenData, RenderFeedByTokenError, RenderFeedByTokenErrors, RenderFeedByTokenResponse, RenderFeedByTokenResponses } from './types.gen'; +export { createFeed, getApiMetadata, getConfigCatalog, getConfigCatalog2, getHealthStatus, getLivenessProbe, getReadinessProbe, listStrategies, type Options, renderFeedByToken } from './sdk.gen'; +export type { ClientOptions, CreateFeedData, CreateFeedError, CreateFeedErrors, CreateFeedResponse, CreateFeedResponses, GetApiMetadataData, GetApiMetadataResponse, GetApiMetadataResponses, GetConfigCatalog2Data, GetConfigCatalog2Response, GetConfigCatalog2Responses, GetConfigCatalogData, GetConfigCatalogError, GetConfigCatalogErrors, GetConfigCatalogResponse, GetConfigCatalogResponses, GetHealthStatusData, GetHealthStatusError, GetHealthStatusErrors, GetHealthStatusResponse, GetHealthStatusResponses, GetLivenessProbeData, GetLivenessProbeResponse, GetLivenessProbeResponses, GetReadinessProbeData, GetReadinessProbeResponse, GetReadinessProbeResponses, ListStrategiesData, ListStrategiesResponse, ListStrategiesResponses, RenderFeedByTokenData, RenderFeedByTokenError, RenderFeedByTokenErrors, RenderFeedByTokenResponse, RenderFeedByTokenResponses } from './types.gen'; diff --git a/frontend/src/api/generated/sdk.gen.ts b/frontend/src/api/generated/sdk.gen.ts index 86a7d09a4..9a39ea4ea 100644 --- a/frontend/src/api/generated/sdk.gen.ts +++ b/frontend/src/api/generated/sdk.gen.ts @@ -1,8 +1,8 @@ // This file is auto-generated by @hey-api/openapi-ts -import type { Client, ClientMeta, Options as Options2, RequestResult, TDataShape } from './client'; +import { type Client, type ClientMeta, type Options as Options2, type RequestResult, type TDataShape, urlSearchParamsBodySerializer } from './client'; import { client } from './client.gen'; -import type { CreateFeedData, CreateFeedErrors, CreateFeedResponses, GetApiMetadataData, GetApiMetadataResponses, GetHealthStatusData, GetHealthStatusErrors, GetHealthStatusResponses, GetLivenessProbeData, GetLivenessProbeResponses, GetReadinessProbeData, GetReadinessProbeResponses, ListStrategiesData, ListStrategiesResponses, RenderFeedByTokenData, RenderFeedByTokenErrors, RenderFeedByTokenResponses } from './types.gen'; +import type { CreateFeedData, CreateFeedErrors, CreateFeedResponses, GetApiMetadataData, GetApiMetadataResponses, GetConfigCatalog2Data, GetConfigCatalog2Responses, GetConfigCatalogData, GetConfigCatalogErrors, GetConfigCatalogResponses, GetHealthStatusData, GetHealthStatusErrors, GetHealthStatusResponses, GetLivenessProbeData, GetLivenessProbeResponses, GetReadinessProbeData, GetReadinessProbeResponses, ListStrategiesData, ListStrategiesResponses, RenderFeedByTokenData, RenderFeedByTokenErrors, RenderFeedByTokenResponses } from './types.gen'; export type Options = Options2 & { /** @@ -25,6 +25,28 @@ export type Options(options?: Options): RequestResult => (options?.client ?? client).get({ url: '/', ...options }); +/** + * Config catalog + * + * Config catalog + */ +export const getConfigCatalog = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/configs', ...options }); + +/** + * Config catalog + * + * Config catalog + */ +export const getConfigCatalog2 = (options?: Options): RequestResult => (options?.client ?? client).options({ + ...urlSearchParamsBodySerializer, + url: '/configs', + ...options, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + ...options?.headers + } +}); + /** * Create a feed * diff --git a/frontend/src/api/generated/types.gen.ts b/frontend/src/api/generated/types.gen.ts index 663352b1c..b0ee3d3f1 100644 --- a/frontend/src/api/generated/types.gen.ts +++ b/frontend/src/api/generated/types.gen.ts @@ -13,7 +13,7 @@ export type GetApiMetadataData = { export type GetApiMetadataResponses = { /** - * returns API information with trailing slash + * returns catalog pointer metadata */ 200: { data: { @@ -23,11 +23,10 @@ export type GetApiMetadataResponses = { openapi_url: string; }; instance: { - featured_feeds: Array<{ - description: string; - path: string; - title: string; - }>; + catalog: { + enabled: boolean; + url: string; + }; feed_creation: { access_token_required: boolean; enabled: boolean; @@ -40,6 +39,108 @@ export type GetApiMetadataResponses = { export type GetApiMetadataResponse = GetApiMetadataResponses[keyof GetApiMetadataResponses]; +export type GetConfigCatalogData = { + body?: never; + path?: never; + query?: never; + url: '/configs'; +}; + +export type GetConfigCatalogErrors = { + /** + * returns 404 when the catalog is disabled + */ + 404: { + error: string; + }; +}; + +export type GetConfigCatalogError = GetConfigCatalogErrors[keyof GetConfigCatalogErrors]; + +export type GetConfigCatalogResponses = { + /** + * returns the merged catalog with CORS headers + */ + 200: { + data: { + configs: Array<{ + channel: { + language: string; + title: string; + url: string; + }; + directory: { + summary: string; + title: string; + topics: Array; + }; + id: string; + parameters: { + defaults: { + blog?: string | null; + id?: string | null; + region?: string | null; + repository?: string | null; + section?: string | null; + user_id?: string | null; + username?: string | null; + }; + schema: { + blog?: { + type: string; + } | null; + id?: { + type: string; + } | null; + region?: { + type: string; + } | null; + repository?: { + type: string; + } | null; + section?: { + type: string; + } | null; + user_id?: { + type: string; + } | null; + username?: { + type: string; + } | null; + }; + }; + path: string; + source: string; + }>; + }; + meta: { + catalog_version: number; + total: number; + }; + success: boolean; + }; +}; + +export type GetConfigCatalogResponse = GetConfigCatalogResponses[keyof GetConfigCatalogResponses]; + +export type GetConfigCatalog2Data = { + body?: { + [key: string]: unknown; + }; + path?: never; + query?: never; + url: '/configs'; +}; + +export type GetConfigCatalog2Responses = { + /** + * responds to preflight requests with CORS headers + */ + 204: void; +}; + +export type GetConfigCatalog2Response = GetConfigCatalog2Responses[keyof GetConfigCatalog2Responses]; + export type CreateFeedData = { body?: { url: string; diff --git a/frontend/src/hooks/useStarterFeeds.ts b/frontend/src/hooks/useStarterFeeds.ts index af1bb640a..84ef8c397 100644 --- a/frontend/src/hooks/useStarterFeeds.ts +++ b/frontend/src/hooks/useStarterFeeds.ts @@ -21,11 +21,7 @@ interface CatalogEnvelope { }; } -const STARTER_FEED_IDS = [ - 'microsoft.com/azure-products', - 'phys.org/weekly', - 'softwareleadweekly.com/issues', -]; +const STARTER_FEED_IDS = ['microsoft.com/azure-products', 'phys.org/weekly', 'softwareleadweekly.com/issues']; /** * Loads starter feeds from the public catalog when feed creation is disabled. @@ -50,7 +46,7 @@ export function useStarterFeeds(metadata?: ApiMetadataRecord, feedCreationEnable const payload = (await response.json()) as CatalogEnvelope; const configs = payload.data?.configs ?? []; const selected = STARTER_FEED_IDS.map((id) => configs.find((entry) => entry.id === id)).filter( - (entry): entry is NonNullable => Boolean(entry), + (entry): entry is NonNullable => Boolean(entry) ); const entries = selected.length > 0 ? selected : configs.slice(0, 3); @@ -61,7 +57,7 @@ export function useStarterFeeds(metadata?: ApiMetadataRecord, feedCreationEnable path: entry.path, title: entry.directory?.title ?? entry.id, description: entry.directory?.summary ?? '', - })), + })) ); } catch { if (!cancelled) setStarterFeeds([]); From 9339f7681929eb2b8b2027a09f4a1c9f076311a2 Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Wed, 19 Aug 2026 19:57:03 +0200 Subject: [PATCH 3/4] fix(api): give catalog OPTIONS a unique OpenAPI operationId Split preflight into its own documented operation, refactor local catalog merge helpers for RuboCop, and regenerate the OpenAPI client. --- app/web/catalog/merge.rb | 46 +++++++++++++++++++------ frontend/src/api/generated/index.ts | 4 +-- frontend/src/api/generated/sdk.gen.ts | 8 ++--- frontend/src/api/generated/types.gen.ts | 6 ++-- public/openapi.yaml | 6 ++-- spec/html2rss/web/api/v1_spec.rb | 7 ++++ 6 files changed, 55 insertions(+), 22 deletions(-) diff --git a/app/web/catalog/merge.rb b/app/web/catalog/merge.rb index f59c4edac..d7e6b7734 100644 --- a/app/web/catalog/merge.rb +++ b/app/web/catalog/merge.rb @@ -52,23 +52,49 @@ def build_local_entry(feed_name, feed_config) 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: { - title: title.to_s, - summary: directory[:summary], - topics: Array(directory[:topics]) - }.compact, - channel: { - url: channel.fetch(:url), - language: channel[:language], - title: channel[:title] || title.to_s - }.compact, + 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 diff --git a/frontend/src/api/generated/index.ts b/frontend/src/api/generated/index.ts index 9d88b35f2..56b4fe60d 100644 --- a/frontend/src/api/generated/index.ts +++ b/frontend/src/api/generated/index.ts @@ -1,4 +1,4 @@ // This file is auto-generated by @hey-api/openapi-ts -export { createFeed, getApiMetadata, getConfigCatalog, getConfigCatalog2, getHealthStatus, getLivenessProbe, getReadinessProbe, listStrategies, type Options, renderFeedByToken } from './sdk.gen'; -export type { ClientOptions, CreateFeedData, CreateFeedError, CreateFeedErrors, CreateFeedResponse, CreateFeedResponses, GetApiMetadataData, GetApiMetadataResponse, GetApiMetadataResponses, GetConfigCatalog2Data, GetConfigCatalog2Response, GetConfigCatalog2Responses, GetConfigCatalogData, GetConfigCatalogError, GetConfigCatalogErrors, GetConfigCatalogResponse, GetConfigCatalogResponses, GetHealthStatusData, GetHealthStatusError, GetHealthStatusErrors, GetHealthStatusResponse, GetHealthStatusResponses, GetLivenessProbeData, GetLivenessProbeResponse, GetLivenessProbeResponses, GetReadinessProbeData, GetReadinessProbeResponse, GetReadinessProbeResponses, ListStrategiesData, ListStrategiesResponse, ListStrategiesResponses, RenderFeedByTokenData, RenderFeedByTokenError, RenderFeedByTokenErrors, RenderFeedByTokenResponse, RenderFeedByTokenResponses } from './types.gen'; +export { createFeed, getApiMetadata, getConfigCatalog, getHealthStatus, getLivenessProbe, getReadinessProbe, listStrategies, type Options, optionsConfigCatalog, renderFeedByToken } from './sdk.gen'; +export type { ClientOptions, CreateFeedData, CreateFeedError, CreateFeedErrors, CreateFeedResponse, CreateFeedResponses, GetApiMetadataData, GetApiMetadataResponse, GetApiMetadataResponses, GetConfigCatalogData, GetConfigCatalogError, GetConfigCatalogErrors, GetConfigCatalogResponse, GetConfigCatalogResponses, GetHealthStatusData, GetHealthStatusError, GetHealthStatusErrors, GetHealthStatusResponse, GetHealthStatusResponses, GetLivenessProbeData, GetLivenessProbeResponse, GetLivenessProbeResponses, GetReadinessProbeData, GetReadinessProbeResponse, GetReadinessProbeResponses, ListStrategiesData, ListStrategiesResponse, ListStrategiesResponses, OptionsConfigCatalogData, OptionsConfigCatalogResponse, OptionsConfigCatalogResponses, RenderFeedByTokenData, RenderFeedByTokenError, RenderFeedByTokenErrors, RenderFeedByTokenResponse, RenderFeedByTokenResponses } from './types.gen'; diff --git a/frontend/src/api/generated/sdk.gen.ts b/frontend/src/api/generated/sdk.gen.ts index 9a39ea4ea..0be980163 100644 --- a/frontend/src/api/generated/sdk.gen.ts +++ b/frontend/src/api/generated/sdk.gen.ts @@ -2,7 +2,7 @@ import { type Client, type ClientMeta, type Options as Options2, type RequestResult, type TDataShape, urlSearchParamsBodySerializer } from './client'; import { client } from './client.gen'; -import type { CreateFeedData, CreateFeedErrors, CreateFeedResponses, GetApiMetadataData, GetApiMetadataResponses, GetConfigCatalog2Data, GetConfigCatalog2Responses, GetConfigCatalogData, GetConfigCatalogErrors, GetConfigCatalogResponses, GetHealthStatusData, GetHealthStatusErrors, GetHealthStatusResponses, GetLivenessProbeData, GetLivenessProbeResponses, GetReadinessProbeData, GetReadinessProbeResponses, ListStrategiesData, ListStrategiesResponses, RenderFeedByTokenData, RenderFeedByTokenErrors, RenderFeedByTokenResponses } from './types.gen'; +import type { CreateFeedData, CreateFeedErrors, CreateFeedResponses, GetApiMetadataData, GetApiMetadataResponses, GetConfigCatalogData, GetConfigCatalogErrors, GetConfigCatalogResponses, GetHealthStatusData, GetHealthStatusErrors, GetHealthStatusResponses, GetLivenessProbeData, GetLivenessProbeResponses, GetReadinessProbeData, GetReadinessProbeResponses, ListStrategiesData, ListStrategiesResponses, OptionsConfigCatalogData, OptionsConfigCatalogResponses, RenderFeedByTokenData, RenderFeedByTokenErrors, RenderFeedByTokenResponses } from './types.gen'; export type Options = Options2 & { /** @@ -33,11 +33,11 @@ export const getApiMetadata = (options?: O export const getConfigCatalog = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/configs', ...options }); /** - * Config catalog + * Config catalog preflight * - * Config catalog + * Config catalog preflight */ -export const getConfigCatalog2 = (options?: Options): RequestResult => (options?.client ?? client).options({ +export const optionsConfigCatalog = (options?: Options): RequestResult => (options?.client ?? client).options({ ...urlSearchParamsBodySerializer, url: '/configs', ...options, diff --git a/frontend/src/api/generated/types.gen.ts b/frontend/src/api/generated/types.gen.ts index b0ee3d3f1..2939a0d7b 100644 --- a/frontend/src/api/generated/types.gen.ts +++ b/frontend/src/api/generated/types.gen.ts @@ -123,7 +123,7 @@ export type GetConfigCatalogResponses = { export type GetConfigCatalogResponse = GetConfigCatalogResponses[keyof GetConfigCatalogResponses]; -export type GetConfigCatalog2Data = { +export type OptionsConfigCatalogData = { body?: { [key: string]: unknown; }; @@ -132,14 +132,14 @@ export type GetConfigCatalog2Data = { url: '/configs'; }; -export type GetConfigCatalog2Responses = { +export type OptionsConfigCatalogResponses = { /** * responds to preflight requests with CORS headers */ 204: void; }; -export type GetConfigCatalog2Response = GetConfigCatalog2Responses[keyof GetConfigCatalog2Responses]; +export type OptionsConfigCatalogResponse = OptionsConfigCatalogResponses[keyof OptionsConfigCatalogResponses]; export type CreateFeedData = { body?: { diff --git a/public/openapi.yaml b/public/openapi.yaml index e38526d10..33a481b7e 100644 --- a/public/openapi.yaml +++ b/public/openapi.yaml @@ -283,8 +283,8 @@ paths: tags: - Catalog options: - description: Config catalog - operationId: getConfigCatalog + description: Config catalog preflight + operationId: optionsConfigCatalog requestBody: content: application/x-www-form-urlencoded: @@ -296,7 +296,7 @@ paths: description: responds to preflight requests with CORS headers security: - {} - summary: Config catalog + summary: Config catalog preflight tags: - Catalog "/feeds": diff --git a/spec/html2rss/web/api/v1_spec.rb b/spec/html2rss/web/api/v1_spec.rb index ab79fd107..e037715f7 100644 --- a/spec/html2rss/web/api/v1_spec.rb +++ b/spec/html2rss/web/api/v1_spec.rb @@ -232,7 +232,14 @@ def relative_feed_link_header(token) expect(JSON.parse(last_response.body)).to eq('error' => 'catalog_disabled') end end + end + describe 'OPTIONS /api/v1/configs', openapi: { + summary: 'Config catalog preflight', + operation_id: 'optionsConfigCatalog', + tags: ['Catalog'], + security: [{}] + } do it 'responds to preflight requests with CORS headers', :aggregate_failures do options '/api/v1/configs' From 88d1ad145cf4c598d802f56588466c9a55a0d583 Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Wed, 19 Aug 2026 19:58:39 +0200 Subject: [PATCH 4/4] fix(api): shorten configs route handler for RuboCop Extract GET and OPTIONS handlers from the catalog route mount. --- app/web/routes/api_v1/configs_routes.rb | 32 +++++++++++++++---------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/app/web/routes/api_v1/configs_routes.rb b/app/web/routes/api_v1/configs_routes.rb index e45d07dde..9c8f0cdd9 100644 --- a/app/web/routes/api_v1/configs_routes.rb +++ b/app/web/routes/api_v1/configs_routes.rb @@ -20,24 +20,30 @@ class << self def call(router) router.on 'configs' do apply_cors!(router) + router.options { handle_options(router) } + router.get { handle_get(router) } + end + end - router.options do - router.response.status = 204 - '' - end - - router.get do - unless Flags.config_catalog_enabled? - router.response.status = 404 - next JSON.generate(error: 'catalog_disabled') - end + private - JSON.generate(Api::V1::Configs.index(router)) - end + # @param router [Roda::RodaRequest] + # @return [String] + def handle_get(router) + unless Flags.config_catalog_enabled? + router.response.status = 404 + return JSON.generate(error: 'catalog_disabled') end + + JSON.generate(Api::V1::Configs.index(router)) end - private + # @param router [Roda::RodaRequest] + # @return [String] + def handle_options(router) + router.response.status = 204 + '' + end # @param router [Roda::RodaRequest] # @return [void]