From d5be3f4bedf1be1c6b8240c8a9c50b3fef114f36 Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sun, 15 Mar 2026 09:53:10 +0100 Subject: [PATCH 1/9] feat: adopt zeitwerk-backed web boot --- Gemfile | 2 +- Gemfile.lock | 4 +- app.rb | 6 +- app/errors/exceptions.rb | 70 ------------------ app/{security => web}/account_manager.rb | 3 - app/{ => web}/api/v1/contract.rb | 8 +-- app/{ => web}/api/v1/create_feed.rb | 51 +++++++------ app/{ => web}/api/v1/feed_metadata.rb | 0 app/{ => web}/api/v1/health.rb | 10 +-- app/{ => web}/api/v1/response.rb | 0 app/{ => web}/api/v1/root_metadata.rb | 2 - app/{ => web}/api/v1/strategies.rb | 3 - app/{security => web}/auth.rb | 9 +-- app/{domain => web}/auto_source.rb | 4 -- app/web/bad_request_error.rb | 13 ++++ app/web/boot.rb | 72 +++++++++++++++++++ app/web/boot/development_reloader.rb | 70 ++++++++++++++++++ app/{domain => web}/cache_ttl.rb | 0 app/{config => web}/config_snapshot.rb | 0 app/{config => web}/environment_validator.rb | 4 -- app/{errors => web}/error_responder.rb | 10 +-- app/{rendering => web}/feed_accept_header.rb | 0 app/{security => web}/feed_access.rb | 11 +-- app/{domain => web}/feed_identity.rb | 2 - app/{rendering => web}/feed_notice_text.rb | 0 .../feed_response_format.rb | 2 - app/{security => web}/feed_token.rb | 20 +++--- app/{ => web}/feeds/cache.rb | 3 - app/{ => web}/feeds/contracts.rb | 0 app/{ => web}/feeds/json_renderer.rb | 6 +- app/{ => web}/feeds/request.rb | 3 - app/{ => web}/feeds/responder.rb | 15 +--- app/{ => web}/feeds/rss_renderer.rb | 6 +- app/{ => web}/feeds/service.rb | 6 +- app/{ => web}/feeds/source_resolver.rb | 17 ++--- app/{config => web}/flags.rb | 0 app/web/forbidden_error.rb | 13 ++++ app/{http => web}/http_cache.rb | 0 app/web/http_error.rb | 29 ++++++++ app/web/internal_server_error.rb | 10 +++ app/{rendering => web}/json_feed_builder.rb | 2 - app/{config => web}/local_config.rb | 2 - app/web/method_not_allowed_error.rb | 13 ++++ app/web/not_found_error.rb | 13 ++++ app/{telemetry => web}/observability.rb | 2 - app/{request => web}/request_context.rb | 0 .../request_context_middleware.rb | 2 - app/{request => web}/request_target.rb | 0 app/{ => web}/routes/api_v1.rb | 5 -- app/{ => web}/routes/feed_pages.rb | 2 - app/{security => web}/security_logger.rb | 2 - app/{security => web}/ssrf_filter_strategy.rb | 2 - app/web/unauthorized_error.rb | 13 ++++ app/{security => web}/url_validator.rb | 0 app/{rendering => web}/xml_builder.rb | 2 - config.ru | 32 ++------- spec/html2rss/web/account_manager_spec.rb | 2 +- .../web/boot/development_reloader_spec.rb | 45 ++++++++++++ spec/html2rss/web/cache_ttl_spec.rb | 2 +- .../web/environment_validator_spec.rb | 2 +- spec/html2rss/web/feed_access_spec.rb | 2 +- spec/html2rss/web/feed_identity_spec.rb | 2 +- spec/html2rss/web/flags_spec.rb | 2 +- spec/html2rss/web/local_config_spec.rb | 2 +- .../web/request_context_middleware_spec.rb | 4 +- spec/html2rss/web/url_validator_spec.rb | 2 +- 66 files changed, 375 insertions(+), 266 deletions(-) delete mode 100644 app/errors/exceptions.rb rename app/{security => web}/account_manager.rb (97%) rename app/{ => web}/api/v1/contract.rb (64%) rename app/{ => web}/api/v1/create_feed.rb (77%) rename app/{ => web}/api/v1/feed_metadata.rb (100%) rename app/{ => web}/api/v1/health.rb (87%) rename app/{ => web}/api/v1/response.rb (100%) rename app/{ => web}/api/v1/root_metadata.rb (96%) rename app/{ => web}/api/v1/strategies.rb (95%) rename app/{security => web}/auth.rb (94%) rename app/{domain => web}/auto_source.rb (94%) create mode 100644 app/web/bad_request_error.rb create mode 100644 app/web/boot.rb create mode 100644 app/web/boot/development_reloader.rb rename app/{domain => web}/cache_ttl.rb (100%) rename app/{config => web}/config_snapshot.rb (100%) rename app/{config => web}/environment_validator.rb (96%) rename app/{errors => web}/error_responder.rb (92%) rename app/{rendering => web}/feed_accept_header.rb (100%) rename app/{security => web}/feed_access.rb (72%) rename app/{domain => web}/feed_identity.rb (97%) rename app/{rendering => web}/feed_notice_text.rb (100%) rename app/{rendering => web}/feed_response_format.rb (98%) rename app/{security => web}/feed_token.rb (93%) rename app/{ => web}/feeds/cache.rb (97%) rename app/{ => web}/feeds/contracts.rb (100%) rename app/{ => web}/feeds/json_renderer.rb (94%) rename app/{ => web}/feeds/request.rb (95%) rename app/{ => web}/feeds/responder.rb (89%) rename app/{ => web}/feeds/rss_renderer.rb (88%) rename app/{ => web}/feeds/service.rb (96%) rename app/{ => web}/feeds/source_resolver.rb (87%) rename app/{config => web}/flags.rb (100%) create mode 100644 app/web/forbidden_error.rb rename app/{http => web}/http_cache.rb (100%) create mode 100644 app/web/http_error.rb create mode 100644 app/web/internal_server_error.rb rename app/{rendering => web}/json_feed_builder.rb (98%) rename app/{config => web}/local_config.rb (97%) create mode 100644 app/web/method_not_allowed_error.rb create mode 100644 app/web/not_found_error.rb rename app/{telemetry => web}/observability.rb (98%) rename app/{request => web}/request_context.rb (100%) rename app/{request => web}/request_context_middleware.rb (98%) rename app/{request => web}/request_target.rb (100%) rename app/{ => web}/routes/api_v1.rb (96%) rename app/{ => web}/routes/feed_pages.rb (94%) rename app/{security => web}/security_logger.rb (99%) rename app/{security => web}/ssrf_filter_strategy.rb (94%) create mode 100644 app/web/unauthorized_error.rb rename app/{security => web}/url_validator.rb (100%) rename app/{rendering => web}/xml_builder.rb (99%) create mode 100644 spec/html2rss/web/boot/development_reloader_spec.rb diff --git a/Gemfile b/Gemfile index 50655c4a..8f9ae8c3 100644 --- a/Gemfile +++ b/Gemfile @@ -17,12 +17,12 @@ gem 'rack-cache' gem 'rack-timeout' gem 'roda' gem 'ssrf_filter' +gem 'zeitwerk' gem 'puma', require: false group :development do gem 'byebug' - gem 'rack-unreloader' gem 'rake', require: false gem 'rubocop', require: false gem 'rubocop-performance', require: false diff --git a/Gemfile.lock b/Gemfile.lock index f8d2a65b..c73a37ee 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -246,7 +246,6 @@ GEM rack-test (2.2.0) rack (>= 1.3) rack-timeout (0.7.0) - rack-unreloader (2.1.0) rails-dom-testing (2.3.0) activesupport (>= 5.0.0) minitest @@ -375,7 +374,6 @@ DEPENDENCIES rack-cache rack-test rack-timeout - rack-unreloader rake roda rspec @@ -393,6 +391,7 @@ DEPENDENCIES vcr webmock yard + zeitwerk CHECKSUMS actionpack (8.1.2) sha256=ced74147a1f0daafaa4bab7f677513fd4d3add574c7839958f7b4f1de44f8423 @@ -483,7 +482,6 @@ CHECKSUMS rack-session (2.1.1) sha256=0b6dc07dea7e4b583f58a48e8b806d4c9f1c6c9214ebc202ec94562cbea2e4e9 rack-test (2.2.0) sha256=005a36692c306ac0b4a9350355ee080fd09ddef1148a5f8b2ac636c720f5c463 rack-timeout (0.7.0) sha256=757337e9793cca999bb73a61fe2a7d4280aa9eefbaf787ce3b98d860749c87d9 - rack-unreloader (2.1.0) sha256=18879cf2ced8ca21a01836bca706f65cce6ebe3f7d9d8a5157ce68ca62c7263a rails-dom-testing (2.3.0) sha256=8acc7953a7b911ca44588bf08737bc16719f431a1cc3091a292bca7317925c1d rails-html-sanitizer (1.7.0) sha256=28b145cceaf9cc214a9874feaa183c3acba036c9592b19886e0e45efc62b1e89 rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a diff --git a/app.rb b/app.rb index 0b2e3db1..b881a826 100644 --- a/app.rb +++ b/app.rb @@ -6,7 +6,9 @@ require 'base64' require 'html2rss' -Dir[File.join(__dir__, 'app/**/*.rb')].each { |file| require file } +require_relative 'app/web/boot' + +Html2rss::Web::Boot.setup!(reloadable: ENV['RACK_ENV'] == 'development') module Html2rss module Web @@ -85,6 +87,8 @@ def development? = self.class.development? plugin :json_parser plugin :public + plugin :head + plugin :not_allowed plugin :exception_page plugin :error_handler do |error| next exception_page(error) if development? diff --git a/app/errors/exceptions.rb b/app/errors/exceptions.rb deleted file mode 100644 index 572cb92f..00000000 --- a/app/errors/exceptions.rb +++ /dev/null @@ -1,70 +0,0 @@ -# frozen_string_literal: true - -module Html2rss - module Web - ## - # Custom exceptions for clean error handling - # These map to HTTP status codes and are handled by Roda's error_handler - - class HttpError < StandardError - DEFAULT_MESSAGE = 'Internal Server Error' - STATUS = 500 - CODE = 'INTERNAL_SERVER_ERROR' - - # @param message [String] - # @return [void] - def initialize(message = self.class::DEFAULT_MESSAGE) - super - end - - # @return [Integer] - def status - self.class::STATUS - end - - # @return [String] - def code - self.class::CODE - end - end - - # HTTP 401 - Authentication required - class UnauthorizedError < HttpError - DEFAULT_MESSAGE = 'Authentication required' - STATUS = 401 - CODE = 'UNAUTHORIZED' - end - - # HTTP 400 - Invalid request - class BadRequestError < HttpError - DEFAULT_MESSAGE = 'Bad Request' - STATUS = 400 - CODE = 'BAD_REQUEST' - end - - # HTTP 403 - Access denied - class ForbiddenError < HttpError - DEFAULT_MESSAGE = 'Forbidden' - STATUS = 403 - CODE = 'FORBIDDEN' - end - - # HTTP 404 - Resource not found - class NotFoundError < HttpError - DEFAULT_MESSAGE = 'Not Found' - STATUS = 404 - CODE = 'NOT_FOUND' - end - - # HTTP 405 - Method not allowed - class MethodNotAllowedError < HttpError - DEFAULT_MESSAGE = 'Method Not Allowed' - STATUS = 405 - CODE = 'METHOD_NOT_ALLOWED' - end - - # HTTP 500 - Server error - class InternalServerError < HttpError - end - end -end diff --git a/app/security/account_manager.rb b/app/web/account_manager.rb similarity index 97% rename from app/security/account_manager.rb rename to app/web/account_manager.rb index 5d60b7b4..45d5a37b 100644 --- a/app/security/account_manager.rb +++ b/app/web/account_manager.rb @@ -1,8 +1,5 @@ # frozen_string_literal: true -require_relative '../config/local_config' -require_relative 'security_logger' - module Html2rss module Web ## diff --git a/app/api/v1/contract.rb b/app/web/api/v1/contract.rb similarity index 64% rename from app/api/v1/contract.rb rename to app/web/api/v1/contract.rb index ed96a475..bc67beb8 100644 --- a/app/api/v1/contract.rb +++ b/app/web/api/v1/contract.rb @@ -1,16 +1,14 @@ # frozen_string_literal: true -require_relative '../../errors/exceptions' - module Html2rss module Web module Api module V1 module Contract CODES = { - unauthorized: UnauthorizedError::CODE, - forbidden: ForbiddenError::CODE, - internal_server_error: InternalServerError::CODE + unauthorized: Html2rss::Web::UnauthorizedError::CODE, + forbidden: Html2rss::Web::ForbiddenError::CODE, + internal_server_error: Html2rss::Web::InternalServerError::CODE }.freeze MESSAGES = { diff --git a/app/api/v1/create_feed.rb b/app/web/api/v1/create_feed.rb similarity index 77% rename from app/api/v1/create_feed.rb rename to app/web/api/v1/create_feed.rb index 85202873..5e1fe797 100644 --- a/app/api/v1/create_feed.rb +++ b/app/web/api/v1/create_feed.rb @@ -3,24 +3,16 @@ require 'time' require 'json' -require_relative '../../security/auth' -require_relative '../../domain/auto_source' -require_relative '../../errors/exceptions' -require_relative '../../security/url_validator' -require_relative '../../telemetry/observability' -require_relative 'feed_metadata' -require_relative 'response' - module Html2rss module Web module Api module V1 ## # Creates stable feed records from authenticated API requests. - module CreateFeed + module CreateFeed # rubocop:disable Metrics/ModuleLength FEED_ATTRIBUTE_KEYS = %i[id name url strategy feed_token public_url json_public_url created_at updated_at].freeze - class << self + class << self # rubocop:disable Metrics/ClassLength # Creates a feed and returns a normalized API success payload. # # @param request [Rack::Request] HTTP request with auth context. @@ -41,14 +33,14 @@ def call(request) # @return [void] def ensure_auto_source_enabled! - raise ForbiddenError, Contract::MESSAGES[:auto_source_disabled] unless AutoSource.enabled? + raise Html2rss::Web::ForbiddenError, Contract::MESSAGES[:auto_source_disabled] unless AutoSource.enabled? end # @param request [Rack::Request] # @return [Hash] def require_account(request) account = Auth.authenticate(request) - raise UnauthorizedError, 'Authentication required' unless account + raise Html2rss::Web::UnauthorizedError, 'Authentication required' unless account account end @@ -57,11 +49,7 @@ def require_account(request) # @param account [Hash] # @return [Html2rss::Web::Api::V1::FeedMetadata::CreateParams] def build_create_params(params, account) - url = params['url'].to_s.strip - raise BadRequestError, 'URL parameter is required' if url.empty? - raise BadRequestError, 'Invalid URL format' unless UrlValidator.valid_url?(url) - raise ForbiddenError, 'URL not allowed for this account' unless UrlValidator.url_allowed?(account, url) - + url = validated_url(params['url'], account) FeedMetadata::CreateParams.new( url: url, name: FeedMetadata.site_title_for(url), @@ -69,13 +57,27 @@ def build_create_params(params, account) ) end + # @param raw_url [String, nil] + # @param account [Hash] + # @return [String] + def validated_url(raw_url, account) + url = raw_url.to_s.strip + raise Html2rss::Web::BadRequestError, 'URL parameter is required' if url.empty? + raise Html2rss::Web::BadRequestError, 'Invalid URL format' unless UrlValidator.valid_url?(url) + unless UrlValidator.url_allowed?(account, url) + raise Html2rss::Web::ForbiddenError, 'URL not allowed for this account' + end + + url + end + # @param raw_strategy [String, nil] # @return [String] def normalize_strategy(raw_strategy) strategy = raw_strategy.to_s.strip strategy = default_strategy if strategy.empty? - raise BadRequestError, 'Unsupported strategy' unless supported_strategies.include?(strategy) + raise Html2rss::Web::BadRequestError, 'Unsupported strategy' unless supported_strategy?(strategy) strategy end @@ -85,6 +87,12 @@ def supported_strategies Html2rss::RequestService.strategy_names.map(&:to_s) end + # @param strategy [String] + # @return [Boolean] + def supported_strategy?(strategy) + supported_strategies.include?(strategy) + end + # @return [String] default strategy identifier. def default_strategy Html2rss::RequestService.default_strategy_name.to_s @@ -95,7 +103,6 @@ def default_strategy def feed_attributes(feed_data) timestamp = Time.now.iso8601 typed_feed = feed_metadata(feed_data) - typed_feed_attributes(typed_feed, timestamp).slice(*FEED_ATTRIBUTE_KEYS) end @@ -109,11 +116,11 @@ def request_params(request) return request.params if raw_body.strip.empty? parsed = JSON.parse(raw_body) - raise BadRequestError, 'Invalid JSON payload' unless parsed.is_a?(Hash) + raise Html2rss::Web::BadRequestError, 'Invalid JSON payload' unless parsed.is_a?(Hash) request.params.merge(parsed) rescue JSON::ParserError - raise BadRequestError, 'Invalid JSON payload' + raise Html2rss::Web::BadRequestError, 'Invalid JSON payload' end # @param request [Rack::Request] @@ -131,7 +138,7 @@ def build_feed_from_request(request) params = build_create_params(request_params(request), account) feed_data = AutoSource.create_stable_feed(params.name, params.url, account, params.strategy) - raise InternalServerError, 'Failed to create feed' unless feed_data + raise Html2rss::Web::InternalServerError, 'Failed to create feed' unless feed_data [params, feed_data] end diff --git a/app/api/v1/feed_metadata.rb b/app/web/api/v1/feed_metadata.rb similarity index 100% rename from app/api/v1/feed_metadata.rb rename to app/web/api/v1/feed_metadata.rb diff --git a/app/api/v1/health.rb b/app/web/api/v1/health.rb similarity index 87% rename from app/api/v1/health.rb rename to app/web/api/v1/health.rb index 76ebcabc..dab8f7ad 100644 --- a/app/api/v1/health.rb +++ b/app/web/api/v1/health.rb @@ -2,12 +2,6 @@ require 'time' -require_relative '../../security/auth' -require_relative '../../errors/exceptions' -require_relative '../../config/local_config' -require_relative 'contract' -require_relative 'response' - module Html2rss module Web module Api @@ -70,14 +64,14 @@ def authorize_health_check!(request) account = Auth.authenticate(request) return if account && account[:username] == 'health-check' - raise UnauthorizedError, 'Health check authentication required' + raise Html2rss::Web::UnauthorizedError, 'Health check authentication required' end # @return [void] def verify_configuration! LocalConfig.yaml rescue StandardError - raise InternalServerError, Contract::MESSAGES[:health_check_failed] + raise Html2rss::Web::InternalServerError, Contract::MESSAGES[:health_check_failed] end end end diff --git a/app/api/v1/response.rb b/app/web/api/v1/response.rb similarity index 100% rename from app/api/v1/response.rb rename to app/web/api/v1/response.rb diff --git a/app/api/v1/root_metadata.rb b/app/web/api/v1/root_metadata.rb similarity index 96% rename from app/api/v1/root_metadata.rb rename to app/web/api/v1/root_metadata.rb index 3792c024..b1c862b5 100644 --- a/app/api/v1/root_metadata.rb +++ b/app/web/api/v1/root_metadata.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require_relative '../../domain/auto_source' - module Html2rss module Web module Api diff --git a/app/api/v1/strategies.rb b/app/web/api/v1/strategies.rb similarity index 95% rename from app/api/v1/strategies.rb rename to app/web/api/v1/strategies.rb index ffa3952e..12582b08 100644 --- a/app/api/v1/strategies.rb +++ b/app/web/api/v1/strategies.rb @@ -1,8 +1,5 @@ # frozen_string_literal: true -require_relative '../../errors/exceptions' -require_relative 'response' - module Html2rss module Web module Api diff --git a/app/security/auth.rb b/app/web/auth.rb similarity index 94% rename from app/security/auth.rb rename to app/web/auth.rb index a162f446..9f16ef75 100644 --- a/app/security/auth.rb +++ b/app/web/auth.rb @@ -1,13 +1,6 @@ # frozen_string_literal: true require 'openssl' -require_relative 'security_logger' -require_relative '../telemetry/observability' -require_relative '../request/request_context' -require_relative 'feed_token' -require_relative 'url_validator' -require_relative 'account_manager' - module Html2rss ## # Web application modules for html2rss @@ -34,7 +27,7 @@ def authenticate(request) # @param strategy [String] # @param expires_in [Integer] seconds (default: 10 years) # @return [String, nil] signed feed token when generation succeeds. - def generate_feed_token(username, url, strategy:, expires_in: Html2rss::Web::DEFAULT_EXPIRY) + def generate_feed_token(username, url, strategy:, expires_in: FeedToken::DEFAULT_EXPIRY) token = FeedToken.create_with_validation( username: username, url: url, diff --git a/app/domain/auto_source.rb b/app/web/auto_source.rb similarity index 94% rename from app/domain/auto_source.rb rename to app/web/auto_source.rb index 31dd37b9..f199904a 100644 --- a/app/domain/auto_source.rb +++ b/app/web/auto_source.rb @@ -1,9 +1,5 @@ # frozen_string_literal: true -require_relative 'feed_identity' -require_relative '../security/auth' -require_relative '../security/feed_access' - module Html2rss module Web ## diff --git a/app/web/bad_request_error.rb b/app/web/bad_request_error.rb new file mode 100644 index 00000000..96108458 --- /dev/null +++ b/app/web/bad_request_error.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Html2rss + module Web + ## + # HTTP 400 error used for invalid client input. + class BadRequestError < HttpError + DEFAULT_MESSAGE = 'Bad Request' + STATUS = 400 + CODE = 'BAD_REQUEST' + end + end +end diff --git a/app/web/boot.rb b/app/web/boot.rb new file mode 100644 index 00000000..ec175a7e --- /dev/null +++ b/app/web/boot.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require 'zeitwerk' + +module Html2rss + module Web + ## + # Boot helpers for code loading and runtime setup. + module Boot + class << self + # @param reloadable [Boolean] + # @return [Zeitwerk::Loader] + def setup!(reloadable: false) + return loader if setup? + + loader.enable_reloading if reloadable + loader.setup + @setup = true # rubocop:disable ThreadSafety/ClassInstanceVariable + loader + end + + # @return [Zeitwerk::Loader] + def loader + @loader ||= build_loader # rubocop:disable ThreadSafety/ClassInstanceVariable + end + + # @return [Boolean] + def setup? + # Loader setup happens once during process boot. + # rubocop:disable ThreadSafety/ClassInstanceVariable + @setup == true + # rubocop:enable ThreadSafety/ClassInstanceVariable + end + + # @return [void] + def eager_load! + loader.eager_load + end + + # @return [void] + def reload! + loader.reload + end + + private + + # @return [Zeitwerk::Loader] + def build_loader + Zeitwerk::Loader.new.tap do |new_loader| + configure_loader(new_loader) + end + end + + # @param new_loader [Zeitwerk::Loader] + # @return [void] + def configure_loader(new_loader) + new_loader.push_dir(app_root, namespace: Html2rss) + new_loader.inflector.inflect('api_v1' => 'ApiV1') + end + + ## + # Returns the application directory that maps to the Html2rss root + # namespace. + # + # @return [String] + def app_root + File.expand_path('..', __dir__) + end + end + end + end +end diff --git a/app/web/boot/development_reloader.rb b/app/web/boot/development_reloader.rb new file mode 100644 index 00000000..82e28a43 --- /dev/null +++ b/app/web/boot/development_reloader.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module Html2rss + module Web + module Boot + ## + # Development-only rack wrapper that reloads Zeitwerk-managed code when + # application files change. + class DevelopmentReloader + WATCH_GLOBS = [ + 'app/**/*.rb', + 'app.rb', + 'config/**/*.rb', + 'config/**/*.yml', + 'config.ru' + ].freeze + + # @param loader [Zeitwerk::Loader] + # @param app_provider [#call] + def initialize(loader:, app_provider:) + @loader = loader + @app_provider = app_provider + @latest_mtime = current_mtime + @reload_mutex = Mutex.new + end + + # @param env [Hash] + # @return [Array<(Integer, Hash, #each)>] + def call(env) + @reload_mutex.synchronize do + reload_if_needed + @app_provider.call.call(env) + end + end + + private + + # @return [void] + def reload_if_needed + mtime = current_mtime + return unless mtime && (!@latest_mtime || mtime > @latest_mtime) + + @loader.reload + reset_runtime_caches! + @latest_mtime = mtime + end + + # @return [Time, nil] + def current_mtime + watched_files.filter_map do |path| + next unless File.file?(path) + + File.mtime(path) + end.max + end + + # @return [Array] + def watched_files + WATCH_GLOBS.flat_map { |pattern| Dir[File.expand_path("../../#{pattern}", __dir__)] } + end + + # @return [void] + def reset_runtime_caches! + Html2rss::Web::LocalConfig.reload!(reason: 'code_reload') if defined?(Html2rss::Web::LocalConfig) + Html2rss::Web::AccountManager.reload!(reason: 'code_reload') if defined?(Html2rss::Web::AccountManager) + end + end + end + end +end diff --git a/app/domain/cache_ttl.rb b/app/web/cache_ttl.rb similarity index 100% rename from app/domain/cache_ttl.rb rename to app/web/cache_ttl.rb diff --git a/app/config/config_snapshot.rb b/app/web/config_snapshot.rb similarity index 100% rename from app/config/config_snapshot.rb rename to app/web/config_snapshot.rb diff --git a/app/config/environment_validator.rb b/app/web/environment_validator.rb similarity index 96% rename from app/config/environment_validator.rb rename to app/web/environment_validator.rb index 292c1f2c..36f0b18e 100644 --- a/app/config/environment_validator.rb +++ b/app/web/environment_validator.rb @@ -1,9 +1,5 @@ # frozen_string_literal: true -require_relative '../security/security_logger' -require_relative '../security/account_manager' -require_relative 'flags' - module Html2rss module Web ## diff --git a/app/errors/error_responder.rb b/app/web/error_responder.rb similarity index 92% rename from app/errors/error_responder.rb rename to app/web/error_responder.rb index ada92634..4deefe59 100644 --- a/app/errors/error_responder.rb +++ b/app/web/error_responder.rb @@ -1,11 +1,5 @@ # frozen_string_literal: true -require_relative '../api/v1/contract' -require_relative '../rendering/feed_response_format' -require_relative '../rendering/json_feed_builder' -require_relative '../telemetry/observability' -require_relative '../request/request_target' - module Html2rss module Web ## @@ -106,14 +100,14 @@ def resolve_status(error) # @param error [StandardError] # @return [String] def client_message_for(error) - error.is_a?(HttpError) ? error.message : HttpError::DEFAULT_MESSAGE + error.is_a?(Html2rss::Web::HttpError) ? error.message : Html2rss::Web::HttpError::DEFAULT_MESSAGE end # @param request [Rack::Request] # @param error [StandardError] # @return [void] def write_internal_error_log(request, error) - return if error.is_a?(HttpError) + return if error.is_a?(Html2rss::Web::HttpError) request.env['rack.errors']&.puts(error_log_line(request, error)) end diff --git a/app/rendering/feed_accept_header.rb b/app/web/feed_accept_header.rb similarity index 100% rename from app/rendering/feed_accept_header.rb rename to app/web/feed_accept_header.rb diff --git a/app/security/feed_access.rb b/app/web/feed_access.rb similarity index 72% rename from app/security/feed_access.rb rename to app/web/feed_access.rb index 4d8ef03c..a77dce62 100644 --- a/app/security/feed_access.rb +++ b/app/web/feed_access.rb @@ -1,10 +1,5 @@ # frozen_string_literal: true -require_relative 'account_manager' -require_relative 'auth' -require_relative 'url_validator' -require_relative '../errors/exceptions' - module Html2rss module Web ## @@ -21,11 +16,11 @@ def account_for_username(username) # @return [Html2rss::Web::FeedToken] def authorize_feed_token!(token) feed_token = Auth.validate_and_decode_feed_token(token) - raise UnauthorizedError, 'Invalid token' unless feed_token + raise Html2rss::Web::UnauthorizedError, 'Invalid token' unless feed_token account = account_for_username(feed_token.username) - raise UnauthorizedError, 'Account not found' unless account - raise ForbiddenError, 'Access Denied' unless UrlValidator.url_allowed?(account, feed_token.url) + raise Html2rss::Web::UnauthorizedError, 'Account not found' unless account + raise Html2rss::Web::ForbiddenError, 'Access Denied' unless UrlValidator.url_allowed?(account, feed_token.url) feed_token end diff --git a/app/domain/feed_identity.rb b/app/web/feed_identity.rb similarity index 97% rename from app/domain/feed_identity.rb rename to app/web/feed_identity.rb index 9f8e4d6d..7bb8c4f1 100644 --- a/app/domain/feed_identity.rb +++ b/app/web/feed_identity.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true require 'digest' -require_relative '../api/v1/feed_metadata' - module Html2rss module Web ## diff --git a/app/rendering/feed_notice_text.rb b/app/web/feed_notice_text.rb similarity index 100% rename from app/rendering/feed_notice_text.rb rename to app/web/feed_notice_text.rb diff --git a/app/rendering/feed_response_format.rb b/app/web/feed_response_format.rb similarity index 98% rename from app/rendering/feed_response_format.rb rename to app/web/feed_response_format.rb index 54fe21a6..f74898a6 100644 --- a/app/rendering/feed_response_format.rb +++ b/app/web/feed_response_format.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require_relative 'feed_accept_header' - module Html2rss module Web ## diff --git a/app/security/feed_token.rb b/app/web/feed_token.rb similarity index 93% rename from app/security/feed_token.rb rename to app/web/feed_token.rb index 50130d55..926e8f9d 100644 --- a/app/security/feed_token.rb +++ b/app/web/feed_token.rb @@ -4,15 +4,8 @@ require 'json' require 'openssl' require 'zlib' -require_relative 'url_validator' - module Html2rss - module Web - DEFAULT_EXPIRY = 315_360_000 # 10 years in seconds - HMAC_ALGORITHM = 'SHA256' - REQUIRED_TOKEN_KEYS = %i[p s].freeze - COMPRESSED_PAYLOAD_KEYS = %i[u l e].freeze - + module Web # rubocop:disable Metrics/ModuleLength ## # Immutable feed token value object with encode/decode and validation helpers. # @@ -25,7 +18,7 @@ module Web # @param strategy [String] # @param expires_in [Integer] # @return [Html2rss::Web::FeedToken, nil] signed token object when inputs are valid. - def self.create_with_validation(username:, url:, secret_key:, strategy:, expires_in: DEFAULT_EXPIRY) + def self.create_with_validation(username:, url:, secret_key:, strategy:, expires_in: FeedToken::DEFAULT_EXPIRY) return unless valid_inputs?(username, url, secret_key, strategy) expires_at = Time.now.to_i + expires_in.to_i @@ -137,7 +130,7 @@ def build_payload(username, url, expires_at, strategy) # @return [String] HMAC digest. def generate_signature(secret_key, payload) data = payload.is_a?(String) ? payload : JSON.generate(payload) - OpenSSL::HMAC.hexdigest(HMAC_ALGORITHM, secret_key, data) + OpenSSL::HMAC.hexdigest(FeedToken::HMAC_ALGORITHM, secret_key, data) end # @param encoded_token [String] @@ -156,7 +149,7 @@ def valid_token_data?(token_data) payload = token_data[:p] signature = token_data[:s] payload.is_a?(Hash) && signature.is_a?(String) && !signature.empty? && - COMPRESSED_PAYLOAD_KEYS.all? { |key| payload[key] } + FeedToken::COMPRESSED_PAYLOAD_KEYS.all? { |key| payload[key] } end # @param username [Object] @@ -188,5 +181,10 @@ def valid_strategy?(strategy) end end end + + FeedToken::DEFAULT_EXPIRY = 315_360_000 # 10 years in seconds + FeedToken::HMAC_ALGORITHM = 'SHA256' + FeedToken::REQUIRED_TOKEN_KEYS = %i[p s].freeze + FeedToken::COMPRESSED_PAYLOAD_KEYS = %i[u l e].freeze end end diff --git a/app/feeds/cache.rb b/app/web/feeds/cache.rb similarity index 97% rename from app/feeds/cache.rb rename to app/web/feeds/cache.rb index 6e663edb..3f8f8a16 100644 --- a/app/feeds/cache.rb +++ b/app/web/feeds/cache.rb @@ -3,9 +3,6 @@ require 'digest' require 'time' -require_relative '../domain/cache_ttl' -require_relative '../security/security_logger' - module Html2rss module Web module Feeds diff --git a/app/feeds/contracts.rb b/app/web/feeds/contracts.rb similarity index 100% rename from app/feeds/contracts.rb rename to app/web/feeds/contracts.rb diff --git a/app/feeds/json_renderer.rb b/app/web/feeds/json_renderer.rb similarity index 94% rename from app/feeds/json_renderer.rb rename to app/web/feeds/json_renderer.rb index bc7fc895..e1fe0c82 100644 --- a/app/feeds/json_renderer.rb +++ b/app/web/feeds/json_renderer.rb @@ -3,10 +3,6 @@ require 'json' require 'time' -require_relative 'contracts' -require_relative '../errors/exceptions' -require_relative '../rendering/json_feed_builder' - module Html2rss module Web module Feeds @@ -44,7 +40,7 @@ def empty_feed(result) # @param result [Html2rss::Web::Feeds::Contracts::RenderResult] # @return [String] def error_feed(result) - JsonFeedBuilder.build_error_feed(message: result.message || HttpError::DEFAULT_MESSAGE) + JsonFeedBuilder.build_error_feed(message: result.message || Html2rss::Web::HttpError::DEFAULT_MESSAGE) end # @param feed [RSS::Rss] diff --git a/app/feeds/request.rb b/app/web/feeds/request.rb similarity index 95% rename from app/feeds/request.rb rename to app/web/feeds/request.rb index 566a8fdc..9519fe68 100644 --- a/app/feeds/request.rb +++ b/app/web/feeds/request.rb @@ -1,9 +1,6 @@ # frozen_string_literal: true require 'cgi' -require_relative 'contracts' -require_relative '../rendering/feed_response_format' - module Html2rss module Web module Feeds diff --git a/app/feeds/responder.rb b/app/web/feeds/responder.rb similarity index 89% rename from app/feeds/responder.rb rename to app/web/feeds/responder.rb index b36a5dc8..d7c1522f 100644 --- a/app/feeds/responder.rb +++ b/app/web/feeds/responder.rb @@ -1,16 +1,5 @@ # frozen_string_literal: true -require_relative '../errors/exceptions' -require_relative '../rendering/feed_response_format' -require_relative 'contracts' -require_relative 'json_renderer' -require_relative 'request' -require_relative 'source_resolver' -require_relative 'rss_renderer' -require_relative 'service' -require_relative '../http/http_cache' -require_relative '../telemetry/observability' - module Html2rss module Web module Feeds @@ -79,7 +68,9 @@ def emit_result(target_kind:, identifier:, resolved_source:, result:) emit_failure( target_kind:, identifier:, - error: InternalServerError.new(result.error_message || result.message || HttpError::DEFAULT_MESSAGE) + error: Html2rss::Web::InternalServerError.new( + result.error_message || result.message || Html2rss::Web::HttpError::DEFAULT_MESSAGE + ) ) end diff --git a/app/feeds/rss_renderer.rb b/app/web/feeds/rss_renderer.rb similarity index 88% rename from app/feeds/rss_renderer.rb rename to app/web/feeds/rss_renderer.rb index dfdf2d64..6313269d 100644 --- a/app/feeds/rss_renderer.rb +++ b/app/web/feeds/rss_renderer.rb @@ -1,9 +1,5 @@ # frozen_string_literal: true -require_relative 'contracts' -require_relative '../errors/exceptions' -require_relative '../rendering/xml_builder' - module Html2rss module Web module Feeds @@ -39,7 +35,7 @@ def empty_feed(result) # @param result [Html2rss::Web::Feeds::Contracts::RenderResult] # @return [String] def error_feed(result) - XmlBuilder.build_error_feed(message: result.message || HttpError::DEFAULT_MESSAGE) + XmlBuilder.build_error_feed(message: result.message || Html2rss::Web::HttpError::DEFAULT_MESSAGE) end end end diff --git a/app/feeds/service.rb b/app/web/feeds/service.rb similarity index 96% rename from app/feeds/service.rb rename to app/web/feeds/service.rb index 2d5963c6..f7bee826 100644 --- a/app/feeds/service.rb +++ b/app/web/feeds/service.rb @@ -1,9 +1,5 @@ # frozen_string_literal: true -require_relative '../errors/exceptions' -require_relative 'cache' -require_relative 'contracts' - module Html2rss module Web module Feeds @@ -94,7 +90,7 @@ def error_result(error, resolved_source, cache_key) Contracts::RenderResult.new( status: :error, payload: nil, - message: HttpError::DEFAULT_MESSAGE, + message: Html2rss::Web::HttpError::DEFAULT_MESSAGE, ttl_seconds: resolved_source.ttl_seconds, cache_key: cache_key, error_message: error.message diff --git a/app/feeds/source_resolver.rb b/app/web/feeds/source_resolver.rb similarity index 87% rename from app/feeds/source_resolver.rb rename to app/web/feeds/source_resolver.rb index 4580c8e1..0bb33a6e 100644 --- a/app/feeds/source_resolver.rb +++ b/app/web/feeds/source_resolver.rb @@ -2,15 +2,6 @@ require 'digest' -require_relative '../security/auth' -require_relative '../security/feed_access' -require_relative '../domain/auto_source' -require_relative '../domain/cache_ttl' -require_relative 'contracts' -require_relative '../errors/exceptions' -require_relative '../config/local_config' -require_relative '../api/v1/contract' - module Html2rss module Web module Feeds @@ -27,7 +18,7 @@ def call(feed_request) when :token resolve_token(feed_request) else - raise BadRequestError, "Unsupported feed target: #{feed_request.target_kind}" + raise Html2rss::Web::BadRequestError, "Unsupported feed target: #{feed_request.target_kind}" end end @@ -99,7 +90,9 @@ def token_cache_identity(token) # @return [void] def ensure_auto_source_enabled! - raise ForbiddenError, Api::V1::Contract::MESSAGES[:auto_source_disabled] unless AutoSource.enabled? + return if AutoSource.enabled? + + raise Html2rss::Web::ForbiddenError, Api::V1::Contract::MESSAGES[:auto_source_disabled] end # @param feed_token [Html2rss::Web::FeedToken] @@ -108,7 +101,7 @@ def resolved_strategy(feed_token) strategy = feed_token.strategy.to_s.strip strategy = Html2rss::RequestService.default_strategy_name.to_s if strategy.empty? supported = Html2rss::RequestService.strategy_names.map(&:to_s) - raise BadRequestError, 'Unsupported strategy' unless supported.include?(strategy) + raise Html2rss::Web::BadRequestError, 'Unsupported strategy' unless supported.include?(strategy) strategy end diff --git a/app/config/flags.rb b/app/web/flags.rb similarity index 100% rename from app/config/flags.rb rename to app/web/flags.rb diff --git a/app/web/forbidden_error.rb b/app/web/forbidden_error.rb new file mode 100644 index 00000000..9a65712f --- /dev/null +++ b/app/web/forbidden_error.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Html2rss + module Web + ## + # HTTP 403 error used when access is denied. + class ForbiddenError < HttpError + DEFAULT_MESSAGE = 'Forbidden' + STATUS = 403 + CODE = 'FORBIDDEN' + end + end +end diff --git a/app/http/http_cache.rb b/app/web/http_cache.rb similarity index 100% rename from app/http/http_cache.rb rename to app/web/http_cache.rb diff --git a/app/web/http_error.rb b/app/web/http_error.rb new file mode 100644 index 00000000..cad61a20 --- /dev/null +++ b/app/web/http_error.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Html2rss + module Web + ## + # Base error type mapped to an HTTP status and API error code. + class HttpError < StandardError + DEFAULT_MESSAGE = 'Internal Server Error' + STATUS = 500 + CODE = 'INTERNAL_SERVER_ERROR' + + # @param message [String] + # @return [void] + def initialize(message = self.class::DEFAULT_MESSAGE) + super + end + + # @return [Integer] + def status + self.class::STATUS + end + + # @return [String] + def code + self.class::CODE + end + end + end +end diff --git a/app/web/internal_server_error.rb b/app/web/internal_server_error.rb new file mode 100644 index 00000000..969a3e82 --- /dev/null +++ b/app/web/internal_server_error.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Html2rss + module Web + ## + # HTTP 500 error used for unexpected internal failures. + class InternalServerError < HttpError + end + end +end diff --git a/app/rendering/json_feed_builder.rb b/app/web/json_feed_builder.rb similarity index 98% rename from app/rendering/json_feed_builder.rb rename to app/web/json_feed_builder.rb index 021be50e..0c1e3187 100644 --- a/app/rendering/json_feed_builder.rb +++ b/app/web/json_feed_builder.rb @@ -2,8 +2,6 @@ require 'json' require 'time' -require_relative 'feed_notice_text' - module Html2rss module Web ## diff --git a/app/config/local_config.rb b/app/web/local_config.rb similarity index 97% rename from app/config/local_config.rb rename to app/web/local_config.rb index 47606f50..4b937d6c 100644 --- a/app/config/local_config.rb +++ b/app/web/local_config.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true require 'yaml' -require_relative 'config_snapshot' -require_relative '../security/security_logger' module Html2rss module Web diff --git a/app/web/method_not_allowed_error.rb b/app/web/method_not_allowed_error.rb new file mode 100644 index 00000000..0ab5e2b3 --- /dev/null +++ b/app/web/method_not_allowed_error.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Html2rss + module Web + ## + # HTTP 405 error used when the route does not support the request verb. + class MethodNotAllowedError < HttpError + DEFAULT_MESSAGE = 'Method Not Allowed' + STATUS = 405 + CODE = 'METHOD_NOT_ALLOWED' + end + end +end diff --git a/app/web/not_found_error.rb b/app/web/not_found_error.rb new file mode 100644 index 00000000..a2d08939 --- /dev/null +++ b/app/web/not_found_error.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Html2rss + module Web + ## + # HTTP 404 error used when a resource cannot be found. + class NotFoundError < HttpError + DEFAULT_MESSAGE = 'Not Found' + STATUS = 404 + CODE = 'NOT_FOUND' + end + end +end diff --git a/app/telemetry/observability.rb b/app/web/observability.rb similarity index 98% rename from app/telemetry/observability.rb rename to app/web/observability.rb index c03413c9..496a0c9b 100644 --- a/app/telemetry/observability.rb +++ b/app/web/observability.rb @@ -4,8 +4,6 @@ require 'logger' require 'time' -require_relative '../request/request_context' - module Html2rss module Web ## diff --git a/app/request/request_context.rb b/app/web/request_context.rb similarity index 100% rename from app/request/request_context.rb rename to app/web/request_context.rb diff --git a/app/request/request_context_middleware.rb b/app/web/request_context_middleware.rb similarity index 98% rename from app/request/request_context_middleware.rb rename to app/web/request_context_middleware.rb index 431fc551..ad49ad75 100644 --- a/app/request/request_context_middleware.rb +++ b/app/web/request_context_middleware.rb @@ -4,8 +4,6 @@ require 'securerandom' require 'time' -require_relative 'request_context' - module Html2rss module Web ## diff --git a/app/request/request_target.rb b/app/web/request_target.rb similarity index 100% rename from app/request/request_target.rb rename to app/web/request_target.rb diff --git a/app/routes/api_v1.rb b/app/web/routes/api_v1.rb similarity index 96% rename from app/routes/api_v1.rb rename to app/web/routes/api_v1.rb index 8e183089..79cf4bfb 100644 --- a/app/routes/api_v1.rb +++ b/app/web/routes/api_v1.rb @@ -1,10 +1,5 @@ # frozen_string_literal: true -require_relative '../api/v1/root_metadata' -require_relative '../api/v1/create_feed' -require_relative '../request/request_target' -require_relative '../feeds/responder' - module Html2rss module Web module Routes diff --git a/app/routes/feed_pages.rb b/app/web/routes/feed_pages.rb similarity index 94% rename from app/routes/feed_pages.rb rename to app/web/routes/feed_pages.rb index 9e1e52bd..0b704dba 100644 --- a/app/routes/feed_pages.rb +++ b/app/web/routes/feed_pages.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require_relative '../request/request_target' - module Html2rss module Web module Routes diff --git a/app/security/security_logger.rb b/app/web/security_logger.rb similarity index 99% rename from app/security/security_logger.rb rename to app/web/security_logger.rb index e3626145..1d15d495 100644 --- a/app/security/security_logger.rb +++ b/app/web/security_logger.rb @@ -4,8 +4,6 @@ require 'json' require 'digest' require 'time' -require_relative '../request/request_context' - module Html2rss module Web ## diff --git a/app/security/ssrf_filter_strategy.rb b/app/web/ssrf_filter_strategy.rb similarity index 94% rename from app/security/ssrf_filter_strategy.rb rename to app/web/ssrf_filter_strategy.rb index 85798a74..2ad3f76f 100644 --- a/app/security/ssrf_filter_strategy.rb +++ b/app/web/ssrf_filter_strategy.rb @@ -2,8 +2,6 @@ require 'ssrf_filter' require 'html2rss' -require_relative '../config/local_config' - module Html2rss module Web ## diff --git a/app/web/unauthorized_error.rb b/app/web/unauthorized_error.rb new file mode 100644 index 00000000..4435299a --- /dev/null +++ b/app/web/unauthorized_error.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Html2rss + module Web + ## + # HTTP 401 error used when authentication is required. + class UnauthorizedError < HttpError + DEFAULT_MESSAGE = 'Authentication required' + STATUS = 401 + CODE = 'UNAUTHORIZED' + end + end +end diff --git a/app/security/url_validator.rb b/app/web/url_validator.rb similarity index 100% rename from app/security/url_validator.rb rename to app/web/url_validator.rb diff --git a/app/rendering/xml_builder.rb b/app/web/xml_builder.rb similarity index 99% rename from app/rendering/xml_builder.rb rename to app/web/xml_builder.rb index d37360e2..399a4d39 100644 --- a/app/rendering/xml_builder.rb +++ b/app/web/xml_builder.rb @@ -2,8 +2,6 @@ require 'nokogiri' require 'time' -require_relative 'feed_notice_text' - module Html2rss module Web ## diff --git a/config.ru b/config.ru index 17be702f..726d1fd1 100644 --- a/config.ru +++ b/config.ru @@ -3,6 +3,7 @@ require 'rubygems' require 'bundler/setup' require 'rack-timeout' +require_relative 'app/web/boot/development_reloader' if ENV.key?('SENTRY_DSN') Bundler.require(:sentry) @@ -21,36 +22,17 @@ end dev = ENV.fetch('RACK_ENV', nil) == 'development' if dev - require 'logger' - require 'rack/unreloader' - - logger = Logger.new($stdout) - logger.level = Logger::INFO - - # Simple Unreloader configuration following official docs - Unreloader = Rack::Unreloader.new( - subclasses: %w[Roda Html2rss], - logger: logger, - reload: true - ) do - Html2rss::Web::App - end - - # Load main app file - Unreloader.require('app.rb') { 'Html2rss::Web::App' } - - # Load all directories - Unreloader handles the rest - Unreloader.require('helpers') - Unreloader.require('app') + require_relative 'app' - run Unreloader + run Html2rss::Web::Boot::DevelopmentReloader.new( + loader: Html2rss::Web::Boot.loader, + app_provider: -> { Html2rss::Web::App.app } + ) else use Rack::Timeout - # Production: load everything upfront for better performance require_relative 'app' - Dir['app/**/*.rb'].each { |f| require_relative f } - Dir['helpers/**/*.rb'].each { |f| require_relative f } + Html2rss::Web::Boot.eager_load! run(Html2rss::Web::App.freeze.app) end diff --git a/spec/html2rss/web/account_manager_spec.rb b/spec/html2rss/web/account_manager_spec.rb index 6f85f18a..f6169db2 100644 --- a/spec/html2rss/web/account_manager_spec.rb +++ b/spec/html2rss/web/account_manager_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -require_relative '../../../app/security/account_manager' +require_relative '../../../app/web/account_manager' RSpec.describe Html2rss::Web::AccountManager do describe '.get_account' do diff --git a/spec/html2rss/web/boot/development_reloader_spec.rb b/spec/html2rss/web/boot/development_reloader_spec.rb new file mode 100644 index 00000000..17307653 --- /dev/null +++ b/spec/html2rss/web/boot/development_reloader_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'zeitwerk' + +require_relative '../../../../app/web/boot/development_reloader' + +RSpec.describe Html2rss::Web::Boot::DevelopmentReloader do + let(:loader) { instance_double(Zeitwerk::Loader, reload: nil) } + let(:rack_app) { ->(_env) { [200, { 'Content-Type' => 'text/plain' }, ['ok']] } } + let(:instance) { described_class.new(loader:, app_provider: -> { rack_app }) } + + before do + stub_const('Html2rss::Web::LocalConfig', Class.new { def self.reload!(reason:); end }) + stub_const('Html2rss::Web::AccountManager', Class.new { def self.reload!(reason:); end }) + allow(Html2rss::Web::LocalConfig).to receive(:reload!) + allow(Html2rss::Web::AccountManager).to receive(:reload!) + end + + it 'reloads code and cached config when watched files change', :aggregate_failures do + previous_mtime = Time.utc(2026, 3, 15, 10, 0, 0) + updated_mtime = Time.utc(2026, 3, 15, 10, 5, 0) + + instance.instance_variable_set(:@latest_mtime, previous_mtime) + allow(instance).to receive(:current_mtime).and_return(updated_mtime) + + status, headers, body = instance.call({}) + + expect(loader).to have_received(:reload).once + expect(Html2rss::Web::LocalConfig).to have_received(:reload!).with(reason: 'code_reload').once + expect(Html2rss::Web::AccountManager).to have_received(:reload!).with(reason: 'code_reload').once + expect([status, headers['Content-Type'], body.each.to_a]).to eq([200, 'text/plain', ['ok']]) + end + + it 'does not reload when the watched tree is unchanged' do + current_mtime = Time.utc(2026, 3, 15, 10, 0, 0) + + instance.instance_variable_set(:@latest_mtime, current_mtime) + allow(instance).to receive(:current_mtime).and_return(current_mtime) + + instance.call({}) + + expect(loader).not_to have_received(:reload) + end +end diff --git a/spec/html2rss/web/cache_ttl_spec.rb b/spec/html2rss/web/cache_ttl_spec.rb index 93f408b1..fa74966c 100644 --- a/spec/html2rss/web/cache_ttl_spec.rb +++ b/spec/html2rss/web/cache_ttl_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -require_relative '../../../app/domain/cache_ttl' +require_relative '../../../app/web/cache_ttl' RSpec.describe Html2rss::Web::CacheTtl do describe '.seconds_from_minutes' do diff --git a/spec/html2rss/web/environment_validator_spec.rb b/spec/html2rss/web/environment_validator_spec.rb index c22b8396..17ceb632 100644 --- a/spec/html2rss/web/environment_validator_spec.rb +++ b/spec/html2rss/web/environment_validator_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' require 'climate_control' -require_relative '../../../app/config/environment_validator' +require_relative '../../../app/web/environment_validator' RSpec.describe Html2rss::Web::EnvironmentValidator do describe '.auto_source_enabled?' do diff --git a/spec/html2rss/web/feed_access_spec.rb b/spec/html2rss/web/feed_access_spec.rb index e749ec69..991253b6 100644 --- a/spec/html2rss/web/feed_access_spec.rb +++ b/spec/html2rss/web/feed_access_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -require_relative '../../../app/security/feed_access' +require_relative '../../../app/web/feed_access' RSpec.describe Html2rss::Web::FeedAccess do describe '.url_allowed_for_username?' do diff --git a/spec/html2rss/web/feed_identity_spec.rb b/spec/html2rss/web/feed_identity_spec.rb index a49c21b9..1e8de16f 100644 --- a/spec/html2rss/web/feed_identity_spec.rb +++ b/spec/html2rss/web/feed_identity_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -require_relative '../../../app/domain/feed_identity' +require_relative '../../../app/web/feed_identity' RSpec.describe Html2rss::Web::FeedIdentity do let(:attributes) do diff --git a/spec/html2rss/web/flags_spec.rb b/spec/html2rss/web/flags_spec.rb index 6fa0f607..bcd1f624 100644 --- a/spec/html2rss/web/flags_spec.rb +++ b/spec/html2rss/web/flags_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' require 'climate_control' -require_relative '../../../app/config/flags' +require_relative '../../../app/web/flags' RSpec.describe Html2rss::Web::Flags do describe '.auto_source_enabled?' do diff --git a/spec/html2rss/web/local_config_spec.rb b/spec/html2rss/web/local_config_spec.rb index b60acfa7..62a7eab4 100644 --- a/spec/html2rss/web/local_config_spec.rb +++ b/spec/html2rss/web/local_config_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -require_relative '../../../app/config/local_config' +require_relative '../../../app/web/local_config' RSpec.describe Html2rss::Web::LocalConfig do def titles_for(*names) diff --git a/spec/html2rss/web/request_context_middleware_spec.rb b/spec/html2rss/web/request_context_middleware_spec.rb index be1b1dca..fc046f50 100644 --- a/spec/html2rss/web/request_context_middleware_spec.rb +++ b/spec/html2rss/web/request_context_middleware_spec.rb @@ -3,8 +3,8 @@ require 'spec_helper' require 'rack/mock' -require_relative '../../../app/request/request_context' -require_relative '../../../app/request/request_context_middleware' +require_relative '../../../app/web/request_context' +require_relative '../../../app/web/request_context_middleware' RSpec.describe Html2rss::Web::RequestContextMiddleware do it 'sets route group in request context' do diff --git a/spec/html2rss/web/url_validator_spec.rb b/spec/html2rss/web/url_validator_spec.rb index e0043ee2..129f20ee 100644 --- a/spec/html2rss/web/url_validator_spec.rb +++ b/spec/html2rss/web/url_validator_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require_relative '../../../app/security/url_validator' +require_relative '../../../app/web/url_validator' RSpec.describe Html2rss::Web::UrlValidator do describe '.url_allowed?' do From 6c4ca54b53590e3d6b82fa7c190fdc2dc17241ee Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sun, 15 Mar 2026 09:53:32 +0100 Subject: [PATCH 2/9] test: enforce zeitwerk compliance in ready gate --- Makefile | 2 ++ README.md | 3 ++ Rakefile | 11 +++++++ spec/html2rss/web/app_integration_spec.rb | 38 +++++++++++------------ spec/html2rss/web/app_spec.rb | 27 ++++++++++------ 5 files changed, 52 insertions(+), 29 deletions(-) diff --git a/Makefile b/Makefile index f7cdb1c0..e6d2afbf 100644 --- a/Makefile +++ b/Makefile @@ -58,6 +58,8 @@ lint: lint-ruby lint-js ## Run all linters (Ruby + Frontend) - errors when issue lint-ruby: ## Run Ruby linter (RuboCop) - errors when issues found @echo "Running RuboCop linting..." bundle exec rubocop + @echo "Running Zeitwerk eager-load check..." + bundle exec rake zeitwerk:verify @echo "Running YARD public-method docs check..." bundle exec rake yard:verify_public_docs @echo "Ruby linting complete!" diff --git a/README.md b/README.md index b3cfa980..f8520b81 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,9 @@ make openapi Dev URLs: Ruby app at `http://localhost:4000`, frontend dev server at `http://localhost:4001`. +Backend code under the `Html2rss::Web` namespace now lives under `app/web/**`, so Zeitwerk can mirror constant paths directly instead of relying on directory-specific namespace wiring. +`make ready` also runs `rake zeitwerk:verify`, which eager-loads the app and fails on loader drift early. + ## Make Targets | Command | Purpose | diff --git a/Rakefile b/Rakefile index 16141b2f..694b12de 100644 --- a/Rakefile +++ b/Rakefile @@ -139,3 +139,14 @@ namespace :yard do puts 'YARD public method documentation check passed.' end end + +namespace :zeitwerk do + desc 'Fail when Zeitwerk cannot eager load the app tree cleanly' + task :verify do + ENV['RACK_ENV'] ||= 'test' + require_relative 'app' + + Html2rss::Web::Boot.eager_load! + puts 'Zeitwerk eager load check passed.' + end +end diff --git a/spec/html2rss/web/app_integration_spec.rb b/spec/html2rss/web/app_integration_spec.rb index 9e88b072..e84b8b20 100644 --- a/spec/html2rss/web/app_integration_spec.rb +++ b/spec/html2rss/web/app_integration_spec.rb @@ -7,7 +7,7 @@ require 'securerandom' require_relative '../../../app' -RSpec.describe Html2rss::Web::App do # rubocop:disable RSpec/MultipleMemoizedHelpers +RSpec.describe Html2rss::Web::App, :aggregate_failures do # rubocop:disable RSpec/MultipleMemoizedHelpers include Rack::Test::Methods let(:app) { described_class.freeze.app } @@ -75,7 +75,7 @@ end describe 'GET /api/v1/feeds/:token' do # rubocop:disable RSpec/MultipleMemoizedHelpers - it 'returns unauthorized for invalid tokens', :aggregate_failures do + it 'returns unauthorized for invalid tokens' do allow(Html2rss::Web::FeedToken).to receive(:decode).and_return(nil) get '/api/v1/feeds/invalid-token', {}, { 'HTTP_ACCEPT' => 'application/xml' } @@ -85,7 +85,7 @@ expect(last_response.body).to include('Invalid token') end - it 'renders the XML feed with cache headers', :aggregate_failures do + it 'renders the XML feed with cache headers' do get "/api/v1/feeds/#{feed_token}", {}, { 'HTTP_HOST' => 'localhost:3000', 'HTTP_ACCEPT' => 'application/xml' } expect(last_response.status).to eq(200) @@ -96,7 +96,7 @@ expect(last_response.body).to eq('') end - it 'accepts URL-escaped public feed tokens', :aggregate_failures do + it 'accepts URL-escaped public feed tokens' do padded_feed_token = 'signed-public-token=' encoded_padded_feed_token = CGI.escape(padded_feed_token) @@ -108,21 +108,21 @@ expect(last_response.headers['Content-Type']).to eq('application/xml') end - it 'renders the JSON feed when requested by extension', :aggregate_failures do + it 'renders the JSON feed when requested by extension' do get "/api/v1/feeds/#{feed_token}.json" expect(last_response.status).to eq(200) expect(last_response.headers['Content-Type']).to eq('application/feed+json') end - it 'renders the JSON feed when requested through Accept', :aggregate_failures do + it 'renders the JSON feed when requested through Accept' do get "/api/v1/feeds/#{feed_token}", {}, { 'HTTP_ACCEPT' => 'application/feed+json' } expect([last_response.status, last_response.headers['Content-Type']]).to eq([200, 'application/feed+json']) expect(last_response.headers['Cache-Control']).to include('max-age=600') expect(last_response.headers['Vary']).to include('Accept') end - it 'prefers the path extension over Accept negotiation', :aggregate_failures do + it 'prefers the path extension over Accept negotiation' do header 'Accept', 'application/feed+json' get "/api/v1/feeds/#{feed_token}.xml" @@ -130,7 +130,7 @@ expect(last_response.headers['Content-Type']).to eq('application/xml') end - it 'honors Accept quality values for feed negotiation', :aggregate_failures do + it 'honors Accept quality values for feed negotiation' do header 'Accept', 'application/xml;q=1.0, application/feed+json;q=0.2' get "/api/v1/feeds/#{feed_token}" @@ -138,7 +138,7 @@ expect(last_response.headers['Content-Type']).to eq('application/xml') end - it 'treats wildcard Accept as rss unless json is more specific', :aggregate_failures do + it 'treats wildcard Accept as rss unless json is more specific' do header 'Accept', '*/*' get "/api/v1/feeds/#{feed_token}" @@ -146,7 +146,7 @@ expect(last_response.headers['Content-Type']).to eq('application/xml') end - it 'ignores q=0 json feed media types during negotiation', :aggregate_failures do + it 'ignores q=0 json feed media types during negotiation' do header 'Accept', 'application/feed+json;q=0, application/xml;q=0.4' get "/api/v1/feeds/#{feed_token}" @@ -154,7 +154,7 @@ expect(last_response.headers['Content-Type']).to eq('application/xml') end - it 'serves HEAD requests for token feeds with negotiated headers only', :aggregate_failures do + it 'serves HEAD requests for token feeds with negotiated headers only' do head "/api/v1/feeds/#{feed_token}", {}, { 'HTTP_ACCEPT' => 'application/feed+json' } expect(last_response.status).to eq(200) @@ -163,7 +163,7 @@ expect(last_response.body).to eq('') end - it 'ignores query param strategy overrides', :aggregate_failures do + it 'ignores query param strategy overrides' do header 'Accept', 'application/xml' get "/api/v1/feeds/#{feed_token}", { 'strategy' => 'invalid' } @@ -227,7 +227,7 @@ def stub_escaped_feed_token(raw_token:, encoded_token:) context 'without authentication' do # rubocop:disable RSpec/MultipleMemoizedHelpers before { allow(Html2rss::Web::Auth).to receive(:authenticate).and_return(nil) } - it 'requires authentication', :aggregate_failures do + it 'requires authentication' do post '/api/v1/feeds', request_payload.to_json, json_headers expect(last_response.status).to eq(401) @@ -239,7 +239,7 @@ def stub_escaped_feed_token(raw_token:, encoded_token:) context 'with authenticated account' do # rubocop:disable RSpec/MultipleMemoizedHelpers before { allow(Html2rss::Web::Auth).to receive(:authenticate).and_return(account) } - it 'returns bad request when JSON payload is invalid', :aggregate_failures do + it 'returns bad request when JSON payload is invalid' do post '/api/v1/feeds', '{ invalid', json_headers expect(last_response.status).to eq(400) @@ -247,7 +247,7 @@ def stub_escaped_feed_token(raw_token:, encoded_token:) expect(json_body).to include('error' => include('message' => 'Invalid JSON payload')) end - it 'returns bad request when URL is missing', :aggregate_failures do + it 'returns bad request when URL is missing' do allow(Html2rss::Web::Api::V1::FeedMetadata).to receive(:site_title_for).and_return('Example') post '/api/v1/feeds', request_payload.merge(url: '').to_json, auth_headers @@ -258,7 +258,7 @@ def stub_escaped_feed_token(raw_token:, encoded_token:) ) end - it 'returns forbidden when URL is not allowed for account', :aggregate_failures do + it 'returns forbidden when URL is not allowed for account' do allow(Html2rss::Web::UrlValidator).to receive(:url_allowed?).and_return(false) post '/api/v1/feeds', request_payload.to_json, auth_headers @@ -269,7 +269,7 @@ def stub_escaped_feed_token(raw_token:, encoded_token:) ) end - it 'returns bad request for unsupported strategy', :aggregate_failures do + it 'returns bad request for unsupported strategy' do post '/api/v1/feeds', request_payload.merge(strategy: 'unsupported').to_json, auth_headers expect(last_response.status).to eq(400) @@ -278,7 +278,7 @@ def stub_escaped_feed_token(raw_token:, encoded_token:) ) end - it 'returns error when feed creation fails', :aggregate_failures do + it 'returns error when feed creation fails' do allow(Html2rss::Web::AutoSource).to receive(:create_stable_feed).and_return(nil) post '/api/v1/feeds', request_payload.to_json, auth_headers @@ -289,7 +289,7 @@ def stub_escaped_feed_token(raw_token:, encoded_token:) ) end - it 'returns created feed metadata', :aggregate_failures do + it 'returns created feed metadata' do post '/api/v1/feeds', request_payload.to_json, auth_headers expect(last_response.status).to eq(201) diff --git a/spec/html2rss/web/app_spec.rb b/spec/html2rss/web/app_spec.rb index b32a509f..59d38bf1 100644 --- a/spec/html2rss/web/app_spec.rb +++ b/spec/html2rss/web/app_spec.rb @@ -77,12 +77,12 @@ def service_error_response_tuple(path) it { expect(described_class).to be < Roda } - context 'with Rack::Test' do + context 'with Rack::Test', :aggregate_failures do include Rack::Test::Methods def app = described_class - it 'serves the homepage with core security headers', :aggregate_failures do + it 'serves the homepage with core security headers' do get '/' expect(last_response).to be_ok @@ -90,7 +90,7 @@ def app = described_class expect(last_response.headers['Strict-Transport-Security']).to include('max-age=31536000') end - it 'serves static feed routes with caching headers', :aggregate_failures do + it 'serves static feed routes with caching headers' do stub_static_feed get '/legacy' @@ -103,7 +103,7 @@ def app = described_class expect(last_response.body).to eq('') end - it 'serves static json feed routes when json is requested by extension', :aggregate_failures do + it 'serves static json feed routes when json is requested by extension' do stub_static_feed get '/legacy.json' @@ -112,7 +112,7 @@ def app = described_class ) end - it 'serves HEAD requests for static feed routes with negotiated headers only', :aggregate_failures do + it 'serves HEAD requests for static feed routes with negotiated headers only' do stub_static_feed head '/legacy' @@ -122,7 +122,14 @@ def app = described_class expect(last_response.body).to eq('') end - it 'coerces string ttl values before cache expiry math', :aggregate_failures do + it 'returns method not allowed for unsupported verbs on token feed routes' do + post '/api/v1/feeds/test-token' + + expect(last_response.status).to eq(405) + expect(last_response.headers['Allow']).to eq('GET') + end + + it 'coerces string ttl values before cache expiry math' do stub_static_feed(ttl: '180') get '/legacy' @@ -131,7 +138,7 @@ def app = described_class expect(last_response.headers['Cache-Control']).to include('max-age=10800') end - it 'renders XML error when static feed generation fails', :aggregate_failures do + it 'renders XML error when static feed generation fails' do allow(Html2rss::Web::XmlBuilder).to receive(:build_error_feed).and_return('') get '/missing-feed' @@ -141,7 +148,7 @@ def app = described_class expect(last_response.body).to eq('') end - it 'renders JSON Feed-shaped errors when static json feed generation fails', :aggregate_failures do + it 'renders JSON Feed-shaped errors when static json feed generation fails' do get '/missing-feed.json' expect(json_feed_error_tuple).to eq( @@ -150,7 +157,7 @@ def app = described_class ) end - it 'renders service failures as non-cacheable xml feed errors', :aggregate_failures do + it 'renders service failures as non-cacheable xml feed errors' do stub_static_service_error('legacy-service-error') expect(service_error_response_tuple('/legacy-service-error')).to eq( @@ -158,7 +165,7 @@ def app = described_class ) end - it 'hides unexpected internal error details from API responses', :aggregate_failures do + it 'hides unexpected internal error details from API responses' do allow(Html2rss::Web::Routes::ApiV1).to receive(:call).and_raise(StandardError, 'boom') get '/api/v1' From de0e657e4f8904ebe6928bb71a6a63652ce31fcc Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sun, 15 Mar 2026 10:09:44 +0100 Subject: [PATCH 3/9] refactor: group web code by concern --- app/web/api/v1/feed_metadata.rb | 43 ++++++ app/web/boot.rb | 8 ++ app/web/{ => config}/config_snapshot.rb | 0 app/web/{ => config}/environment_validator.rb | 0 app/web/{ => config}/flags.rb | 0 app/web/{ => config}/local_config.rb | 0 app/web/{ => domain}/auto_source.rb | 2 +- app/web/{ => domain}/cache_ttl.rb | 0 app/web/{ => errors}/bad_request_error.rb | 0 app/web/{ => errors}/error_responder.rb | 0 app/web/{ => errors}/forbidden_error.rb | 0 app/web/{ => errors}/http_error.rb | 0 app/web/{ => errors}/internal_server_error.rb | 0 .../{ => errors}/method_not_allowed_error.rb | 0 app/web/{ => errors}/not_found_error.rb | 0 app/web/{ => errors}/unauthorized_error.rb | 0 app/web/feed_identity.rb | 55 -------- app/web/{ => http}/http_cache.rb | 0 app/web/{ => rendering}/feed_accept_header.rb | 0 app/web/{ => rendering}/feed_notice_text.rb | 0 .../{ => rendering}/feed_response_format.rb | 0 app/web/{ => rendering}/json_feed_builder.rb | 0 app/web/{ => rendering}/xml_builder.rb | 0 app/web/{ => request}/request_context.rb | 0 .../request_context_middleware.rb | 0 app/web/{ => request}/request_target.rb | 0 app/web/routes/api_v1.rb | 128 +----------------- app/web/routes/api_v1/feed_routes.rb | 30 ++++ app/web/routes/api_v1/health_routes.rb | 50 +++++++ app/web/routes/api_v1/metadata_routes.rb | 74 ++++++++++ app/web/{ => security}/account_manager.rb | 0 app/web/{ => security}/auth.rb | 0 app/web/{ => security}/feed_access.rb | 0 app/web/{ => security}/feed_token.rb | 0 app/web/{ => security}/security_logger.rb | 0 .../{ => security}/ssrf_filter_strategy.rb | 0 app/web/{ => security}/url_validator.rb | 0 app/web/{ => telemetry}/observability.rb | 0 spec/html2rss/web/account_manager_spec.rb | 2 +- .../v1/feed_metadata_spec.rb} | 13 +- spec/html2rss/web/cache_ttl_spec.rb | 2 +- .../web/environment_validator_spec.rb | 2 +- spec/html2rss/web/feed_access_spec.rb | 2 +- spec/html2rss/web/flags_spec.rb | 2 +- spec/html2rss/web/local_config_spec.rb | 2 +- .../web/request_context_middleware_spec.rb | 4 +- spec/html2rss/web/url_validator_spec.rb | 2 +- 47 files changed, 225 insertions(+), 196 deletions(-) rename app/web/{ => config}/config_snapshot.rb (100%) rename app/web/{ => config}/environment_validator.rb (100%) rename app/web/{ => config}/flags.rb (100%) rename app/web/{ => config}/local_config.rb (100%) rename app/web/{ => domain}/auto_source.rb (94%) rename app/web/{ => domain}/cache_ttl.rb (100%) rename app/web/{ => errors}/bad_request_error.rb (100%) rename app/web/{ => errors}/error_responder.rb (100%) rename app/web/{ => errors}/forbidden_error.rb (100%) rename app/web/{ => errors}/http_error.rb (100%) rename app/web/{ => errors}/internal_server_error.rb (100%) rename app/web/{ => errors}/method_not_allowed_error.rb (100%) rename app/web/{ => errors}/not_found_error.rb (100%) rename app/web/{ => errors}/unauthorized_error.rb (100%) delete mode 100644 app/web/feed_identity.rb rename app/web/{ => http}/http_cache.rb (100%) rename app/web/{ => rendering}/feed_accept_header.rb (100%) rename app/web/{ => rendering}/feed_notice_text.rb (100%) rename app/web/{ => rendering}/feed_response_format.rb (100%) rename app/web/{ => rendering}/json_feed_builder.rb (100%) rename app/web/{ => rendering}/xml_builder.rb (100%) rename app/web/{ => request}/request_context.rb (100%) rename app/web/{ => request}/request_context_middleware.rb (100%) rename app/web/{ => request}/request_target.rb (100%) create mode 100644 app/web/routes/api_v1/feed_routes.rb create mode 100644 app/web/routes/api_v1/health_routes.rb create mode 100644 app/web/routes/api_v1/metadata_routes.rb rename app/web/{ => security}/account_manager.rb (100%) rename app/web/{ => security}/auth.rb (100%) rename app/web/{ => security}/feed_access.rb (100%) rename app/web/{ => security}/feed_token.rb (100%) rename app/web/{ => security}/security_logger.rb (100%) rename app/web/{ => security}/ssrf_filter_strategy.rb (100%) rename app/web/{ => security}/url_validator.rb (100%) rename app/web/{ => telemetry}/observability.rb (100%) rename spec/html2rss/web/{feed_identity_spec.rb => api/v1/feed_metadata_spec.rb} (63%) diff --git a/app/web/api/v1/feed_metadata.rb b/app/web/api/v1/feed_metadata.rb index 04651df5..dde42b33 100644 --- a/app/web/api/v1/feed_metadata.rb +++ b/app/web/api/v1/feed_metadata.rb @@ -17,6 +17,49 @@ def site_title_for(url) rescue StandardError nil end + + # @param attributes [Hash{Symbol=>Object}] + # @return [Html2rss::Web::Api::V1::FeedMetadata::Metadata] + def build(attributes) + Metadata.new(**metadata_attributes(attributes)) + end + + private + + # @param attributes [Hash{Symbol=>Object}] + # @return [Hash{Symbol=>Object}] + def metadata_attributes(attributes) + { + id: stable_id(attributes[:username], attributes[:url], attributes[:identity_token]), + name: attributes[:name], + url: attributes[:url], + username: attributes[:username], + strategy: attributes[:strategy], + feed_token: attributes[:feed_token], + public_url: public_url(attributes[:feed_token]), + json_public_url: json_public_url(attributes[:feed_token]) + } + end + + # @param username [String] + # @param url [String] + # @param token [String] + # @return [String] + def stable_id(username, url, token) + Digest::SHA256.hexdigest("#{username}:#{url}:#{token}")[0..15] + end + + # @param feed_token [String] + # @return [String] + def public_url(feed_token) + "/api/v1/feeds/#{feed_token}" + end + + # @param feed_token [String] + # @return [String] + def json_public_url(feed_token) + "#{public_url(feed_token)}.json" + end end ## diff --git a/app/web/boot.rb b/app/web/boot.rb index ec175a7e..93cd1871 100644 --- a/app/web/boot.rb +++ b/app/web/boot.rb @@ -55,9 +55,17 @@ def build_loader # @return [void] def configure_loader(new_loader) new_loader.push_dir(app_root, namespace: Html2rss) + collapsed_web_dirs.each { |path| new_loader.collapse(path) } new_loader.inflector.inflect('api_v1' => 'ApiV1') end + # @return [Array] + def collapsed_web_dirs + %w[config domain errors http rendering request security telemetry].map do |dir| + File.join(app_root, 'web', dir) + end + end + ## # Returns the application directory that maps to the Html2rss root # namespace. diff --git a/app/web/config_snapshot.rb b/app/web/config/config_snapshot.rb similarity index 100% rename from app/web/config_snapshot.rb rename to app/web/config/config_snapshot.rb diff --git a/app/web/environment_validator.rb b/app/web/config/environment_validator.rb similarity index 100% rename from app/web/environment_validator.rb rename to app/web/config/environment_validator.rb diff --git a/app/web/flags.rb b/app/web/config/flags.rb similarity index 100% rename from app/web/flags.rb rename to app/web/config/flags.rb diff --git a/app/web/local_config.rb b/app/web/config/local_config.rb similarity index 100% rename from app/web/local_config.rb rename to app/web/config/local_config.rb diff --git a/app/web/auto_source.rb b/app/web/domain/auto_source.rb similarity index 94% rename from app/web/auto_source.rb rename to app/web/domain/auto_source.rb index f199904a..4b1d09c7 100644 --- a/app/web/auto_source.rb +++ b/app/web/domain/auto_source.rb @@ -27,7 +27,7 @@ def create_stable_feed(name, url, token_data, strategy = 'ssrf_filter') feed_token = Auth.generate_feed_token(token_data[:username], url, strategy: strategy) return nil unless feed_token - FeedIdentity.metadata(metadata_attributes(name, url, token_data, strategy, feed_token)) + Api::V1::FeedMetadata.build(metadata_attributes(name, url, token_data, strategy, feed_token)) end private diff --git a/app/web/cache_ttl.rb b/app/web/domain/cache_ttl.rb similarity index 100% rename from app/web/cache_ttl.rb rename to app/web/domain/cache_ttl.rb diff --git a/app/web/bad_request_error.rb b/app/web/errors/bad_request_error.rb similarity index 100% rename from app/web/bad_request_error.rb rename to app/web/errors/bad_request_error.rb diff --git a/app/web/error_responder.rb b/app/web/errors/error_responder.rb similarity index 100% rename from app/web/error_responder.rb rename to app/web/errors/error_responder.rb diff --git a/app/web/forbidden_error.rb b/app/web/errors/forbidden_error.rb similarity index 100% rename from app/web/forbidden_error.rb rename to app/web/errors/forbidden_error.rb diff --git a/app/web/http_error.rb b/app/web/errors/http_error.rb similarity index 100% rename from app/web/http_error.rb rename to app/web/errors/http_error.rb diff --git a/app/web/internal_server_error.rb b/app/web/errors/internal_server_error.rb similarity index 100% rename from app/web/internal_server_error.rb rename to app/web/errors/internal_server_error.rb diff --git a/app/web/method_not_allowed_error.rb b/app/web/errors/method_not_allowed_error.rb similarity index 100% rename from app/web/method_not_allowed_error.rb rename to app/web/errors/method_not_allowed_error.rb diff --git a/app/web/not_found_error.rb b/app/web/errors/not_found_error.rb similarity index 100% rename from app/web/not_found_error.rb rename to app/web/errors/not_found_error.rb diff --git a/app/web/unauthorized_error.rb b/app/web/errors/unauthorized_error.rb similarity index 100% rename from app/web/unauthorized_error.rb rename to app/web/errors/unauthorized_error.rb diff --git a/app/web/feed_identity.rb b/app/web/feed_identity.rb deleted file mode 100644 index 7bb8c4f1..00000000 --- a/app/web/feed_identity.rb +++ /dev/null @@ -1,55 +0,0 @@ -# frozen_string_literal: true - -require 'digest' -module Html2rss - module Web - ## - # Builds stable identifiers and public URLs for feed contract objects. - module FeedIdentity - class << self - # @param username [String] - # @param url [String] - # @param token [String] - # @return [String] - def stable_id(username, url, token) - Digest::SHA256.hexdigest("#{username}:#{url}:#{token}")[0..15] - end - - # @param attributes [Hash{Symbol=>Object}] - # @return [Html2rss::Web::Api::V1::FeedMetadata::Metadata] - def metadata(attributes) - Api::V1::FeedMetadata::Metadata.new(**metadata_attributes(attributes)) - end - - private - - # @param attributes [Hash{Symbol=>Object}] - # @return [Hash{Symbol=>Object}] - def metadata_attributes(attributes) - { - id: stable_id(attributes[:username], attributes[:url], attributes[:identity_token]), - name: attributes[:name], - url: attributes[:url], - username: attributes[:username], - strategy: attributes[:strategy], - feed_token: attributes[:feed_token], - public_url: public_url(attributes[:feed_token]), - json_public_url: json_public_url(attributes[:feed_token]) - } - end - - # @param feed_token [String] - # @return [String] - def public_url(feed_token) - "/api/v1/feeds/#{feed_token}" - end - - # @param feed_token [String] - # @return [String] - def json_public_url(feed_token) - "#{public_url(feed_token)}.json" - end - end - end - end -end diff --git a/app/web/http_cache.rb b/app/web/http/http_cache.rb similarity index 100% rename from app/web/http_cache.rb rename to app/web/http/http_cache.rb diff --git a/app/web/feed_accept_header.rb b/app/web/rendering/feed_accept_header.rb similarity index 100% rename from app/web/feed_accept_header.rb rename to app/web/rendering/feed_accept_header.rb diff --git a/app/web/feed_notice_text.rb b/app/web/rendering/feed_notice_text.rb similarity index 100% rename from app/web/feed_notice_text.rb rename to app/web/rendering/feed_notice_text.rb diff --git a/app/web/feed_response_format.rb b/app/web/rendering/feed_response_format.rb similarity index 100% rename from app/web/feed_response_format.rb rename to app/web/rendering/feed_response_format.rb diff --git a/app/web/json_feed_builder.rb b/app/web/rendering/json_feed_builder.rb similarity index 100% rename from app/web/json_feed_builder.rb rename to app/web/rendering/json_feed_builder.rb diff --git a/app/web/xml_builder.rb b/app/web/rendering/xml_builder.rb similarity index 100% rename from app/web/xml_builder.rb rename to app/web/rendering/xml_builder.rb diff --git a/app/web/request_context.rb b/app/web/request/request_context.rb similarity index 100% rename from app/web/request_context.rb rename to app/web/request/request_context.rb diff --git a/app/web/request_context_middleware.rb b/app/web/request/request_context_middleware.rb similarity index 100% rename from app/web/request_context_middleware.rb rename to app/web/request/request_context_middleware.rb diff --git a/app/web/request_target.rb b/app/web/request/request_target.rb similarity index 100% rename from app/web/request_target.rb rename to app/web/request/request_target.rb diff --git a/app/web/routes/api_v1.rb b/app/web/routes/api_v1.rb index 79cf4bfb..df8f8f63 100644 --- a/app/web/routes/api_v1.rb +++ b/app/web/routes/api_v1.rb @@ -19,133 +19,11 @@ def call(router) RequestTarget.mark!(router, RequestTarget::API) router.response['Content-Type'] = 'application/json' - mount_openapi_spec(router) - mount_health(router) - mount_strategies(router) - mount_feeds(router) - mount_root(router) + HealthRoutes.call(router) + FeedRoutes.call(router) + MetadataRoutes.call(router) end end - - private - - # @param router [Roda::RodaRequest] - # @return [void] - def mount_openapi_spec(router) - router.on 'openapi.yaml' do - router.get do - router.response['Content-Type'] = 'application/yaml' - openapi_spec_contents - end - end - end - - # @param router [Roda::RodaRequest] - # @return [void] - def mount_health(router) - router.on 'health' do - mount_health_subroutes(router) - - router.get do - render_json(Api::V1::Health.show(router)) - end - end - end - - # @param router [Roda::RodaRequest] - # @return [void] - def mount_health_subroutes(router) - mount_readiness_health(router) - mount_liveness_health(router) - end - - # @param router [Roda::RodaRequest] - # @return [void] - def mount_readiness_health(router) - router.on 'ready' do - router.get do - render_json(Api::V1::Health.ready(router)) - end - end - end - - # @param router [Roda::RodaRequest] - # @return [void] - def mount_liveness_health(router) - router.on 'live' do - router.get do - render_json(Api::V1::Health.live(router)) - end - end - end - - # @param router [Roda::RodaRequest] - # @return [void] - def mount_strategies(router) - router.on 'strategies' do - router.get do - render_json(Api::V1::Strategies.index(router)) - end - end - end - - # @param router [Roda::RodaRequest] - # @return [void] - def mount_feeds(router) - router.on 'feeds' do - router.get String do |token| - RequestTarget.mark!(router, RequestTarget::FEED) - Feeds::Responder.call(request: router, target_kind: :token, identifier: token) - end - - router.post do - render_json(Api::V1::CreateFeed.call(router)) - end - end - end - - # @param router [Roda::RodaRequest] - # @return [void] - def mount_root(router) - router.get do - render_json(Api::V1::Response.success(data: api_root_payload(router))) - end - end - - # @param router [Roda::RodaRequest] - # @return [Hash{Symbol=>Object}] API capability payload. - # @option return [Hash] :api API metadata block. - # @option return [String] :name API display name. - # @option return [String] :description human-readable API description. - # @option return [String] :openapi_url absolute OpenAPI spec URL. - # @option return [Hash] :demo public demo metadata block. - def api_root_payload(router) - Api::V1::RootMetadata.build(router) - end - - # @param payload [Hash{Symbol=>Object}] - # @return [String] serialized JSON payload. - def render_json(payload) - JSON.generate(payload) - end - - # @return [String] absolute file path for bundled OpenAPI spec. - def openapi_spec_path - File.expand_path('../../docs/api/v1/openapi.yaml', __dir__) - end - - # @return [String] YAML OpenAPI content, with minimal fallback. - def openapi_spec_contents - return File.read(openapi_spec_path) if File.exist?(openapi_spec_path) - - <<~YAML - openapi: 3.0.3 - info: - title: html2rss-web API - version: 1.0.0 - paths: {} - YAML - end end end end diff --git a/app/web/routes/api_v1/feed_routes.rb b/app/web/routes/api_v1/feed_routes.rb new file mode 100644 index 00000000..fdfd7c93 --- /dev/null +++ b/app/web/routes/api_v1/feed_routes.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Html2rss + module Web + module Routes + module ApiV1 + ## + # Mounts feed-related API routes under `/api/v1/feeds`. + module FeedRoutes + class << self + # @param router [Roda::RodaRequest] + # @return [void] + def call(router) + router.on 'feeds' do + router.get String do |token| + RequestTarget.mark!(router, RequestTarget::FEED) + Feeds::Responder.call(request: router, target_kind: :token, identifier: token) + end + + router.post do + JSON.generate(Api::V1::CreateFeed.call(router)) + end + end + end + end + end + end + end + end +end diff --git a/app/web/routes/api_v1/health_routes.rb b/app/web/routes/api_v1/health_routes.rb new file mode 100644 index 00000000..dc1d1929 --- /dev/null +++ b/app/web/routes/api_v1/health_routes.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Html2rss + module Web + module Routes + module ApiV1 + ## + # Mounts health and readiness endpoints under `/api/v1/health`. + module HealthRoutes + class << self + # @param router [Roda::RodaRequest] + # @return [void] + def call(router) + router.on 'health' do + mount_readiness(router) + mount_liveness(router) + + router.get do + JSON.generate(Api::V1::Health.show(router)) + end + end + end + + private + + # @param router [Roda::RodaRequest] + # @return [void] + def mount_readiness(router) + router.on 'ready' do + router.get do + JSON.generate(Api::V1::Health.ready(router)) + end + end + end + + # @param router [Roda::RodaRequest] + # @return [void] + def mount_liveness(router) + router.on 'live' do + router.get do + JSON.generate(Api::V1::Health.live(router)) + end + end + end + end + end + end + end + end +end diff --git a/app/web/routes/api_v1/metadata_routes.rb b/app/web/routes/api_v1/metadata_routes.rb new file mode 100644 index 00000000..89ebbfae --- /dev/null +++ b/app/web/routes/api_v1/metadata_routes.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +module Html2rss + module Web + module Routes + module ApiV1 + ## + # Mounts OpenAPI, root metadata, and strategy listing endpoints. + module MetadataRoutes + class << self + # @param router [Roda::RodaRequest] + # @return [void] + def call(router) + mount_openapi_spec(router) + mount_strategies(router) + mount_root(router) + end + + private + + # @param router [Roda::RodaRequest] + # @return [void] + def mount_openapi_spec(router) + router.on 'openapi.yaml' do + router.get do + router.response['Content-Type'] = 'application/yaml' + openapi_spec_contents + end + end + end + + # @param router [Roda::RodaRequest] + # @return [void] + def mount_strategies(router) + router.on 'strategies' do + router.get do + JSON.generate(Api::V1::Strategies.index(router)) + end + end + end + + # @param router [Roda::RodaRequest] + # @return [void] + def mount_root(router) + router.is do + router.get do + JSON.generate(Api::V1::Response.success(data: Api::V1::RootMetadata.build(router))) + end + end + end + + # @return [String] + def openapi_spec_path + File.expand_path('../../../../docs/api/v1/openapi.yaml', __dir__) + end + + # @return [String] + def openapi_spec_contents + return File.read(openapi_spec_path) if File.exist?(openapi_spec_path) + + <<~YAML + openapi: 3.0.3 + info: + title: html2rss-web API + version: 1.0.0 + paths: {} + YAML + end + end + end + end + end + end +end diff --git a/app/web/account_manager.rb b/app/web/security/account_manager.rb similarity index 100% rename from app/web/account_manager.rb rename to app/web/security/account_manager.rb diff --git a/app/web/auth.rb b/app/web/security/auth.rb similarity index 100% rename from app/web/auth.rb rename to app/web/security/auth.rb diff --git a/app/web/feed_access.rb b/app/web/security/feed_access.rb similarity index 100% rename from app/web/feed_access.rb rename to app/web/security/feed_access.rb diff --git a/app/web/feed_token.rb b/app/web/security/feed_token.rb similarity index 100% rename from app/web/feed_token.rb rename to app/web/security/feed_token.rb diff --git a/app/web/security_logger.rb b/app/web/security/security_logger.rb similarity index 100% rename from app/web/security_logger.rb rename to app/web/security/security_logger.rb diff --git a/app/web/ssrf_filter_strategy.rb b/app/web/security/ssrf_filter_strategy.rb similarity index 100% rename from app/web/ssrf_filter_strategy.rb rename to app/web/security/ssrf_filter_strategy.rb diff --git a/app/web/url_validator.rb b/app/web/security/url_validator.rb similarity index 100% rename from app/web/url_validator.rb rename to app/web/security/url_validator.rb diff --git a/app/web/observability.rb b/app/web/telemetry/observability.rb similarity index 100% rename from app/web/observability.rb rename to app/web/telemetry/observability.rb diff --git a/spec/html2rss/web/account_manager_spec.rb b/spec/html2rss/web/account_manager_spec.rb index f6169db2..b8252e4f 100644 --- a/spec/html2rss/web/account_manager_spec.rb +++ b/spec/html2rss/web/account_manager_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -require_relative '../../../app/web/account_manager' +require_relative '../../../app/web/security/account_manager' RSpec.describe Html2rss::Web::AccountManager do describe '.get_account' do diff --git a/spec/html2rss/web/feed_identity_spec.rb b/spec/html2rss/web/api/v1/feed_metadata_spec.rb similarity index 63% rename from spec/html2rss/web/feed_identity_spec.rb rename to spec/html2rss/web/api/v1/feed_metadata_spec.rb index 1e8de16f..dc8780b4 100644 --- a/spec/html2rss/web/feed_identity_spec.rb +++ b/spec/html2rss/web/api/v1/feed_metadata_spec.rb @@ -1,10 +1,11 @@ # frozen_string_literal: true require 'spec_helper' +require 'digest' -require_relative '../../../app/web/feed_identity' +require_relative '../../../../../app' -RSpec.describe Html2rss::Web::FeedIdentity do +RSpec.describe Html2rss::Web::Api::V1::FeedMetadata do let(:attributes) do { name: 'Example Feed', @@ -18,7 +19,7 @@ let(:expected_hash) do { - id: described_class.stable_id('alice', 'https://example.com/articles', 'account-token'), + id: Digest::SHA256.hexdigest('alice:https://example.com/articles:account-token')[0..15], name: 'Example Feed', url: 'https://example.com/articles', username: 'alice', @@ -29,9 +30,9 @@ } end - describe '.metadata' do - it 'builds stable feed metadata from domain identity inputs' do - expect(described_class.metadata(attributes).to_h).to eq(expected_hash) + describe '.build' do + it 'builds stable feed metadata from creation attributes' do + expect(described_class.build(attributes).to_h).to eq(expected_hash) end end end diff --git a/spec/html2rss/web/cache_ttl_spec.rb b/spec/html2rss/web/cache_ttl_spec.rb index fa74966c..cf4db836 100644 --- a/spec/html2rss/web/cache_ttl_spec.rb +++ b/spec/html2rss/web/cache_ttl_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -require_relative '../../../app/web/cache_ttl' +require_relative '../../../app/web/domain/cache_ttl' RSpec.describe Html2rss::Web::CacheTtl do describe '.seconds_from_minutes' do diff --git a/spec/html2rss/web/environment_validator_spec.rb b/spec/html2rss/web/environment_validator_spec.rb index 17ceb632..0a5ed65e 100644 --- a/spec/html2rss/web/environment_validator_spec.rb +++ b/spec/html2rss/web/environment_validator_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' require 'climate_control' -require_relative '../../../app/web/environment_validator' +require_relative '../../../app/web/config/environment_validator' RSpec.describe Html2rss::Web::EnvironmentValidator do describe '.auto_source_enabled?' do diff --git a/spec/html2rss/web/feed_access_spec.rb b/spec/html2rss/web/feed_access_spec.rb index 991253b6..1da5951f 100644 --- a/spec/html2rss/web/feed_access_spec.rb +++ b/spec/html2rss/web/feed_access_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -require_relative '../../../app/web/feed_access' +require_relative '../../../app/web/security/feed_access' RSpec.describe Html2rss::Web::FeedAccess do describe '.url_allowed_for_username?' do diff --git a/spec/html2rss/web/flags_spec.rb b/spec/html2rss/web/flags_spec.rb index bcd1f624..ff56e6a3 100644 --- a/spec/html2rss/web/flags_spec.rb +++ b/spec/html2rss/web/flags_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' require 'climate_control' -require_relative '../../../app/web/flags' +require_relative '../../../app/web/config/flags' RSpec.describe Html2rss::Web::Flags do describe '.auto_source_enabled?' do diff --git a/spec/html2rss/web/local_config_spec.rb b/spec/html2rss/web/local_config_spec.rb index 62a7eab4..074ffb28 100644 --- a/spec/html2rss/web/local_config_spec.rb +++ b/spec/html2rss/web/local_config_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -require_relative '../../../app/web/local_config' +require_relative '../../../app/web/config/local_config' RSpec.describe Html2rss::Web::LocalConfig do def titles_for(*names) diff --git a/spec/html2rss/web/request_context_middleware_spec.rb b/spec/html2rss/web/request_context_middleware_spec.rb index fc046f50..6aae8308 100644 --- a/spec/html2rss/web/request_context_middleware_spec.rb +++ b/spec/html2rss/web/request_context_middleware_spec.rb @@ -3,8 +3,8 @@ require 'spec_helper' require 'rack/mock' -require_relative '../../../app/web/request_context' -require_relative '../../../app/web/request_context_middleware' +require_relative '../../../app/web/request/request_context' +require_relative '../../../app/web/request/request_context_middleware' RSpec.describe Html2rss::Web::RequestContextMiddleware do it 'sets route group in request context' do diff --git a/spec/html2rss/web/url_validator_spec.rb b/spec/html2rss/web/url_validator_spec.rb index 129f20ee..04eabecf 100644 --- a/spec/html2rss/web/url_validator_spec.rb +++ b/spec/html2rss/web/url_validator_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require_relative '../../../app/web/url_validator' +require_relative '../../../app/web/security/url_validator' RSpec.describe Html2rss::Web::UrlValidator do describe '.url_allowed?' do From cafa1bf17b47a9e4231384c11f9910aceec87cf0 Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sun, 15 Mar 2026 10:09:49 +0100 Subject: [PATCH 4/9] test: tighten feed response smoke coverage --- spec/html2rss/web/feeds/responder_spec.rb | 7 +++++-- spec/smoke/docker_spec.rb | 24 ++++++++++++++++++++--- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/spec/html2rss/web/feeds/responder_spec.rb b/spec/html2rss/web/feeds/responder_spec.rb index d5813021..2dd1d2e9 100644 --- a/spec/html2rss/web/feeds/responder_spec.rb +++ b/spec/html2rss/web/feeds/responder_spec.rb @@ -60,10 +60,12 @@ def resolved_source expect(response_tuple(write_response)).to eq([200, 'application/xml', '']) end - it 'marks the response as cacheable' do + it 'marks the response as cacheable', :aggregate_failures do write_response + expect(response['Cache-Control']).to include('max-age=600') expect(response['Cache-Control']).to include('public') + expect(response['Vary']).to eq('Accept') end it 'emits success after writing the response' do @@ -112,10 +114,11 @@ def resolved_source expect(response_tuple(write_response)).to eq([500, 'application/feed+json', '{"title":"Error"}']) end - it 'marks the response as non-cacheable' do + it 'marks the response as non-cacheable', :aggregate_failures do write_response expect(response['Cache-Control']).to include('no-store') + expect(response['Vary']).to eq('Accept') end end diff --git a/spec/smoke/docker_spec.rb b/spec/smoke/docker_spec.rb index 94dc7971..1faf8aa1 100644 --- a/spec/smoke/docker_spec.rb +++ b/spec/smoke/docker_spec.rb @@ -16,6 +16,13 @@ def get_json(path, headers: {}) perform_request(uri, request) end + def get_response(path, headers: {}) + uri = URI.join(base_url, path) + request = Net::HTTP::Get.new(uri, headers) + response = Net::HTTP.start(uri.host, uri.port) { |http| http.request(request) } + [response, response.body.to_s] + end + def post_json(path, body:, headers: {}) uri = URI.join(base_url, path) request = Net::HTTP::Post.new(uri, headers.merge('Content-Type' => 'application/json')) @@ -28,6 +35,18 @@ def perform_request(uri, request) [response, response.body.to_s.empty? ? {} : JSON.parse(response.body)] end + def expect_created_feed_response(body) + expect(body.fetch('success')).to be(true) + expect(body.dig('data', 'feed', 'public_url')).to match(%r{^/api/v1/feeds/}) + expect(body.dig('data', 'feed', 'json_public_url')).to match(%r{^/api/v1/feeds/.+\.json$}) + end + + def expect_json_feed_response(path) + feed_response, = get_response(path, headers: { 'Accept' => 'application/feed+json' }) + expect(feed_response['Content-Type']).to include('application/feed+json') + expect(feed_response.code).not_to eq('401') + end + it 'exposes health endpoints without authentication requirements', :aggregate_failures do response, payload = get_json('/api/v1/health/ready') expect(response).to be_a(Net::HTTPOK) @@ -72,9 +91,8 @@ def perform_request(uri, request) headers: { 'Authorization' => "Bearer #{feed_token}" }) expect(response.code).to eq('201') - expect(body.fetch('success')).to be(true) - expect(body.dig('data', 'feed', 'public_url')).to match(%r{^/api/v1/feeds/}) - expect(body.dig('data', 'feed', 'json_public_url')).to match(%r{^/api/v1/feeds/.+\.json$}) + expect_created_feed_response(body) + expect_json_feed_response(body.dig('data', 'feed', 'json_public_url')) end it 'returns forbidden for authenticated creation when auto source is disabled', :aggregate_failures do From f6e58a947009128abff9b5dff494eae4aa3a7cac Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sun, 15 Mar 2026 10:13:33 +0100 Subject: [PATCH 5/9] refactor: extract web boot setup --- README.md | 1 + app.rb | 8 +--- app/web/boot/setup.rb | 37 +++++++++++++++++ docs/ai-agent-app-web.md | 60 ++++++++++++++++++++++++++++ spec/html2rss/web/boot/setup_spec.rb | 30 ++++++++++++++ 5 files changed, 129 insertions(+), 7 deletions(-) create mode 100644 app/web/boot/setup.rb create mode 100644 docs/ai-agent-app-web.md create mode 100644 spec/html2rss/web/boot/setup_spec.rb diff --git a/README.md b/README.md index f8520b81..fdd1215c 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,7 @@ Dev URLs: Ruby app at `http://localhost:4000`, frontend dev server at `http://lo Backend code under the `Html2rss::Web` namespace now lives under `app/web/**`, so Zeitwerk can mirror constant paths directly instead of relying on directory-specific namespace wiring. `make ready` also runs `rake zeitwerk:verify`, which eager-loads the app and fails on loader drift early. +For contributors and AI agents changing backend structure, follow the placement rules in [docs/ai-agent-app-web.md](docs/ai-agent-app-web.md). ## Make Targets diff --git a/app.rb b/app.rb index b881a826..e053a56d 100644 --- a/app.rb +++ b/app.rb @@ -9,6 +9,7 @@ require_relative 'app/web/boot' Html2rss::Web::Boot.setup!(reloadable: ENV['RACK_ENV'] == 'development') +Html2rss::Web::Boot::Setup.call! module Html2rss module Web @@ -33,13 +34,6 @@ class App < Roda def self.development? = EnvironmentValidator.development? def development? = self.class.development? - EnvironmentValidator.validate_environment! - EnvironmentValidator.validate_production_security! - Flags.validate! - - Html2rss::RequestService.register_strategy(:ssrf_filter, SsrfFilterStrategy) - Html2rss::RequestService.default_strategy_name = :ssrf_filter - Html2rss::RequestService.unregister_strategy(:faraday) opts.merge!(check_dynamic_arity: false, check_arity: :warn) use RequestContextMiddleware use Rack::Cache, metastore: 'file:./tmp/rack-cache-meta', entitystore: 'file:./tmp/rack-cache-body', diff --git a/app/web/boot/setup.rb b/app/web/boot/setup.rb new file mode 100644 index 00000000..2c22f364 --- /dev/null +++ b/app/web/boot/setup.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Html2rss + module Web + module Boot + ## + # Applies boot-time runtime configuration outside the Roda class body. + module Setup + class << self + # Validates environment configuration and wires the request service. + # + # @return [void] + def call! + validate_environment! + configure_request_service! + end + + private + + # @return [void] + def validate_environment! + EnvironmentValidator.validate_environment! + EnvironmentValidator.validate_production_security! + Flags.validate! + end + + # @return [void] + def configure_request_service! + Html2rss::RequestService.register_strategy(:ssrf_filter, SsrfFilterStrategy) + Html2rss::RequestService.default_strategy_name = :ssrf_filter + Html2rss::RequestService.unregister_strategy(:faraday) + end + end + end + end + end +end diff --git a/docs/ai-agent-app-web.md b/docs/ai-agent-app-web.md new file mode 100644 index 00000000..f6bc502b --- /dev/null +++ b/docs/ai-agent-app-web.md @@ -0,0 +1,60 @@ +# `app/web` Rules For AI Agents + +This file is intentionally prescriptive. If you are an AI coding agent changing Ruby backend code, follow these rules before adding files or moving code. + +## Namespace Contract + +- `app/` is the Zeitwerk root for `Html2rss`. +- `app/web/**` maps to the `Html2rss::Web` namespace. +- Do not add `require_relative` calls between files under `app/web/**` unless the file is a non-Zeitwerk boot entrypoint. +- Path, filename, and constant name must match. If a constant is `Html2rss::Web::SecurityLogger`, the file belongs at `app/web/security/security_logger.rb`. + +## Directory Placement + +Use the narrowest concern folder that fits the object. + +- `app/web/api/`: API contract and endpoint implementation objects. +- `app/web/boot/`: process boot, loader setup, dev reload, runtime setup. +- `app/web/config/`: environment flags, local config loading, config snapshots. +- `app/web/domain/`: backend domain helpers that do not belong to API, request, rendering, or security. +- `app/web/errors/`: error classes and error response serialization. +- `app/web/feeds/`: feed fetching, rendering orchestration, cache use, feed service contracts. +- `app/web/http/`: low-level HTTP response/cache helpers. +- `app/web/rendering/`: content negotiation and feed output builders. +- `app/web/request/`: request-scoped context and middleware. +- `app/web/routes/`: Roda route composition only. +- `app/web/security/`: auth, token handling, account access, SSRF request strategy, security logging. +- `app/web/telemetry/`: observability event emission only. + +## Placement Heuristics + +- Put code in `routes/` only if it mounts or composes Roda request branches. +- Put code in `api/` only if it is specific to `/api/v1` contracts or endpoint behavior. +- Put code in `feeds/` if it is part of fetching, resolving, rendering, or caching feeds. +- Put code in `domain/` only as a last resort. If a better concern folder exists, use it. +- Do not create generic buckets such as `services`, `utils`, `helpers`, or `concerns`. + +## Consolidation Rules + +- Prefer concern folders over a flat `app/web/` root. +- Do not merge unrelated objects just to reduce file count. +- Consolidate only when one file is clearly a thin wrapper around another concept and the merged object still has a single responsibility. +- If a file defines multiple top-level constants, stop and check whether Zeitwerk naming or the public API would become less clear. + +## Boot And Runtime Rules + +- `app.rb` should declare the Roda app and its Rack/Roda plugins. +- Process-level boot side effects belong in `app/web/boot/**`. +- Register external runtime integrations, validate environment, and configure shared services in boot objects, not inline in the Roda class body. + +## Route Rules + +- Keep route composition centralized in `app/web/routes/**`. +- Split route modules by endpoint concern when a route file grows, but preserve matching order. +- Root metadata routes must use exact matching (`r.is`) so they do not swallow subpaths. + +## Change Checklist + +- Update or add specs for the behavior you moved. +- Run `docker compose -f .devcontainer/docker-compose.yml exec -T app make ready`. +- Smoke the app at `http://127.0.0.1:4001/` when request or UI behavior changed. diff --git a/spec/html2rss/web/boot/setup_spec.rb b/spec/html2rss/web/boot/setup_spec.rb new file mode 100644 index 00000000..32fbde0f --- /dev/null +++ b/spec/html2rss/web/boot/setup_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require 'spec_helper' + +require_relative '../../../../app/web/boot/setup' + +RSpec.describe Html2rss::Web::Boot::Setup do + describe '.call!' do + before do + allow(Html2rss::Web::EnvironmentValidator).to receive(:validate_environment!) + allow(Html2rss::Web::EnvironmentValidator).to receive(:validate_production_security!) + allow(Html2rss::Web::Flags).to receive(:validate!) + allow(Html2rss::RequestService).to receive(:register_strategy) + allow(Html2rss::RequestService).to receive(:default_strategy_name=) + allow(Html2rss::RequestService).to receive(:unregister_strategy) + end + + it 'validates environment state and configures the request service', :aggregate_failures do + described_class.call! + + expect(Html2rss::Web::EnvironmentValidator).to have_received(:validate_environment!).once + expect(Html2rss::Web::EnvironmentValidator).to have_received(:validate_production_security!).once + expect(Html2rss::Web::Flags).to have_received(:validate!).once + expect(Html2rss::RequestService).to have_received(:register_strategy) + .with(:ssrf_filter, Html2rss::Web::SsrfFilterStrategy).once + expect(Html2rss::RequestService).to have_received(:default_strategy_name=).with(:ssrf_filter).once + expect(Html2rss::RequestService).to have_received(:unregister_strategy).with(:faraday).once + end + end +end From b363dd357356b6dbcb9271830408b71a9deb6b15 Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sun, 15 Mar 2026 10:20:03 +0100 Subject: [PATCH 6/9] fix: restore live feed creation flow --- app/web/routes/api_v1/metadata_routes.rb | 14 +++++++++++++- frontend/src/hooks/useApiMetadata.ts | 11 +++++------ spec/html2rss/web/api/v1_spec.rb | 10 ++++++++++ 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/app/web/routes/api_v1/metadata_routes.rb b/app/web/routes/api_v1/metadata_routes.rb index 89ebbfae..0e87aac0 100644 --- a/app/web/routes/api_v1/metadata_routes.rb +++ b/app/web/routes/api_v1/metadata_routes.rb @@ -42,9 +42,15 @@ def mount_strategies(router) # @param router [Roda::RodaRequest] # @return [void] def mount_root(router) + router.root do + router.get do + render_root_metadata(router) + end + end + router.is do router.get do - JSON.generate(Api::V1::Response.success(data: Api::V1::RootMetadata.build(router))) + render_root_metadata(router) end end end @@ -54,6 +60,12 @@ def openapi_spec_path File.expand_path('../../../../docs/api/v1/openapi.yaml', __dir__) end + # @param router [Roda::RodaRequest] + # @return [String] + def render_root_metadata(router) + JSON.generate(Api::V1::Response.success(data: Api::V1::RootMetadata.build(router))) + end + # @return [String] def openapi_spec_contents return File.read(openapi_spec_path) if File.exist?(openapi_spec_path) diff --git a/frontend/src/hooks/useApiMetadata.ts b/frontend/src/hooks/useApiMetadata.ts index d61937ad..33f1eb71 100644 --- a/frontend/src/hooks/useApiMetadata.ts +++ b/frontend/src/hooks/useApiMetadata.ts @@ -1,6 +1,4 @@ import { useEffect, useState } from 'preact/hooks'; -import { getApiMetadata } from '../api/generated'; -import { apiClient } from '../api/client'; import type { ApiMetadataRecord } from '../api/contracts'; interface ApiMetadataState { @@ -23,13 +21,14 @@ export function useApiMetadata() { setState((prev) => ({ ...prev, isLoading: true, error: null })); try { - const response = await getApiMetadata({ - client: apiClient, + const response = await fetch('/api/v1', { signal: controller.signal, + headers: { Accept: 'application/json' }, }); - const metadata = response.data?.data as unknown as ApiMetadataRecord | undefined; + const payload = (await response.json()) as { success?: boolean; data?: unknown }; + const metadata = payload.data as ApiMetadataRecord | undefined; - if (response.error || !response.data?.success || !metadata?.instance) { + if (!response.ok || !payload.success || !metadata?.instance) { throw new Error('Invalid response format from API metadata'); } diff --git a/spec/html2rss/web/api/v1_spec.rb b/spec/html2rss/web/api/v1_spec.rb index 925723f7..21885910 100644 --- a/spec/html2rss/web/api/v1_spec.rb +++ b/spec/html2rss/web/api/v1_spec.rb @@ -127,6 +127,16 @@ def json_feed_headers_tuple 'access_token_required' => true ) end + + it 'returns API information with trailing slash', :aggregate_failures do + get '/api/v1/' + + expect(last_response.status).to eq(200) + expect(last_response.content_type).to include('application/json') + + json = expect_success_response(last_response) + expect(json.dig('data', 'api', 'name')).to eq('html2rss-web API') + end end describe 'GET /api/v1/openapi.yaml', openapi: { From ad724805260bf978f09a1c943b2b8e4e8fa9f065 Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sun, 15 Mar 2026 10:27:50 +0100 Subject: [PATCH 7/9] test: cover metadata contract path --- frontend/src/__tests__/App.contract.test.tsx | 44 +++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/frontend/src/__tests__/App.contract.test.tsx b/frontend/src/__tests__/App.contract.test.tsx index e684a665..766f44b9 100644 --- a/frontend/src/__tests__/App.contract.test.tsx +++ b/frontend/src/__tests__/App.contract.test.tsx @@ -30,7 +30,7 @@ describe('App contract', () => { }) ); }), - http.get('/api/v1/feeds/generated-token.json', ({ request }) => { + http.get('/api/v1/feeds/generated-token', ({ request }) => { expect(request.headers.get('accept')).toBe('application/feed+json'); return HttpResponse.json( @@ -67,4 +67,46 @@ describe('App contract', () => { expect(screen.getByText('Contract Item')).toBeInTheDocument(); }); }); + + it('loads instance metadata from /api/v1 without trailing slash', async () => { + let slashlessMetadataRequests = 0; + let trailingSlashMetadataRequests = 0; + + server.use( + http.get('/api/v1', () => { + slashlessMetadataRequests += 1; + + return HttpResponse.json({ + success: true, + data: { + api: { + name: 'html2rss-web API', + description: 'RESTful API for converting websites to RSS feeds', + openapi_url: 'http://example.test/api/v1/openapi.yaml', + }, + instance: { + feed_creation: { + enabled: true, + access_token_required: true, + }, + }, + }, + }); + }), + http.get('/api/v1/', () => { + trailingSlashMetadataRequests += 1; + + return HttpResponse.text('', { status: 404 }); + }) + ); + + render(); + + await screen.findByLabelText('Page URL'); + + expect(screen.getByRole('button', { name: 'Generate feed URL' })).toBeInTheDocument(); + expect(screen.queryByText('Instance metadata unavailable')).not.toBeInTheDocument(); + expect(slashlessMetadataRequests).toBeGreaterThanOrEqual(1); + expect(trailingSlashMetadataRequests).toBe(0); + }); }); From eb7342153cc7de4d99530f56b537dce9495f6d40 Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sun, 15 Mar 2026 10:30:39 +0100 Subject: [PATCH 8/9] fix: harden metadata loading failures --- frontend/src/__tests__/App.contract.test.tsx | 13 +++++++++++++ frontend/src/hooks/useApiMetadata.ts | 18 +++++++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/frontend/src/__tests__/App.contract.test.tsx b/frontend/src/__tests__/App.contract.test.tsx index 766f44b9..d6642618 100644 --- a/frontend/src/__tests__/App.contract.test.tsx +++ b/frontend/src/__tests__/App.contract.test.tsx @@ -109,4 +109,17 @@ describe('App contract', () => { expect(slashlessMetadataRequests).toBeGreaterThanOrEqual(1); expect(trailingSlashMetadataRequests).toBe(0); }); + + it('shows the metadata unavailable notice when /api/v1 responds with non-JSON content', async () => { + server.use( + http.get('/api/v1', () => HttpResponse.text('not-json', { status: 502 })), + http.get('/api/v1/', () => HttpResponse.text('', { status: 404 })) + ); + + render(); + + await screen.findByText('Instance metadata unavailable'); + + expect(screen.getByText('Invalid response format from API metadata')).toBeInTheDocument(); + }); }); diff --git a/frontend/src/hooks/useApiMetadata.ts b/frontend/src/hooks/useApiMetadata.ts index 33f1eb71..6feccba3 100644 --- a/frontend/src/hooks/useApiMetadata.ts +++ b/frontend/src/hooks/useApiMetadata.ts @@ -7,6 +7,11 @@ interface ApiMetadataState { error: string | null; } +interface ApiMetadataPayload { + success?: boolean; + data?: unknown; +} + export function useApiMetadata() { const [state, setState] = useState({ metadata: null, @@ -25,7 +30,7 @@ export function useApiMetadata() { signal: controller.signal, headers: { Accept: 'application/json' }, }); - const payload = (await response.json()) as { success?: boolean; data?: unknown }; + const payload = await parseMetadataPayload(response); const metadata = payload.data as ApiMetadataRecord | undefined; if (!response.ok || !payload.success || !metadata?.instance) { @@ -54,3 +59,14 @@ export function useApiMetadata() { return state; } + +async function parseMetadataPayload(response: Response): Promise { + const body = await response.text(); + if (!body.trim()) return {}; + + try { + return JSON.parse(body) as ApiMetadataPayload; + } catch { + return {}; + } +} From 307e9c7854de7c947aa8eab5f29a7b873569cf09 Mon Sep 17 00:00:00 2001 From: Gil Desmarais Date: Sun, 15 Mar 2026 10:43:33 +0100 Subject: [PATCH 9/9] fix: harden feed creation contracts --- frontend/src/__tests__/App.contract.test.tsx | 25 +++++++++++++++++++ .../useFeedConversion.contract.test.ts | 22 ++++++++++++++++ frontend/src/hooks/useFeedConversion.ts | 1 + frontend/src/styles/main.css | 8 ++++-- 4 files changed, 54 insertions(+), 2 deletions(-) diff --git a/frontend/src/__tests__/App.contract.test.tsx b/frontend/src/__tests__/App.contract.test.tsx index d6642618..d694fd11 100644 --- a/frontend/src/__tests__/App.contract.test.tsx +++ b/frontend/src/__tests__/App.contract.test.tsx @@ -122,4 +122,29 @@ describe('App contract', () => { expect(screen.getByText('Invalid response format from API metadata')).toBeInTheDocument(); }); + + it('reopens token recovery when a saved token is rejected by /api/v1/feeds', async () => { + authenticate(); + + server.use( + http.post('/api/v1/feeds', async () => + HttpResponse.json({ success: false, error: { message: 'Unauthorized' } }, { status: 401 }) + ) + ); + + render(); + + await screen.findByLabelText('Page URL'); + + fireEvent.input(screen.getByLabelText('Page URL'), { + target: { value: 'https://example.com/articles' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Generate feed URL' })); + + await screen.findByText('Access token was rejected. Paste a valid token to continue.'); + + expect(screen.getByText('Add access token')).toBeInTheDocument(); + expect(screen.queryByText('Feed generation failed')).not.toBeInTheDocument(); + expect(window.sessionStorage.getItem('html2rss_access_token')).toBeNull(); + }); }); diff --git a/frontend/src/__tests__/useFeedConversion.contract.test.ts b/frontend/src/__tests__/useFeedConversion.contract.test.ts index 0e257264..cbdcc39b 100644 --- a/frontend/src/__tests__/useFeedConversion.contract.test.ts +++ b/frontend/src/__tests__/useFeedConversion.contract.test.ts @@ -60,4 +60,26 @@ describe('useFeedConversion contract', () => { expect(result.current.result).toBeNull(); expect(result.current.error).toBe('URL parameter is required'); }); + + it('normalizes malformed successful responses', async () => { + server.use( + http.post('/api/v1/feeds', async () => + HttpResponse.text('not-json', { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + ) + ); + + const { result } = renderHook(() => useFeedConversion()); + + await act(async () => { + await expect( + result.current.convertFeed('https://example.com/articles', 'ssrf_filter', 'token') + ).rejects.toThrow('Invalid response format from feed creation API'); + }); + + expect(result.current.result).toBeNull(); + expect(result.current.error).toBe('Invalid response format from feed creation API'); + }); }); diff --git a/frontend/src/hooks/useFeedConversion.ts b/frontend/src/hooks/useFeedConversion.ts index de8eaa57..134c2220 100644 --- a/frontend/src/hooks/useFeedConversion.ts +++ b/frontend/src/hooks/useFeedConversion.ts @@ -85,6 +85,7 @@ export function useFeedConversion() { } const toErrorMessage = (error: unknown): string => { + if (error instanceof SyntaxError) return 'Invalid response format from feed creation API'; if (error instanceof Error) return error.message; if (typeof error === 'string' && error.trim()) return error; diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css index 7ffc51c0..229fbebe 100644 --- a/frontend/src/styles/main.css +++ b/frontend/src/styles/main.css @@ -627,8 +627,11 @@ a:focus-visible { .result-actions--quiet { width: min(100%, 40rem); margin: 0 auto; + display: flex; + flex-wrap: nowrap; + align-items: center; justify-items: start; - justify-content: start; + justify-content: flex-start; gap: var(--space-4); margin-top: calc(var(--space-2) * -1); } @@ -697,7 +700,7 @@ a:focus-visible { } .result-actions { - grid-template-columns: repeat(2, minmax(0, auto)); + grid-template-columns: none; } .utility-strip__items { @@ -727,6 +730,7 @@ a:focus-visible { } .result-actions--quiet { + display: grid; grid-template-columns: 1fr; gap: var(--space-3); }