Skip to content

Commit b61602d

Browse files
committed
feat(runtime): rebuild feed and api behavior around typed v1 services
Intent: make the relaunch backend read like a new runtime with explicit contracts instead of accumulated legacy handlers. Goals: centralize feed resolution, rendering, auth, request context, error handling, and API behavior under the new app/web runtime. Changes: add the v1 feed, api, security, rendering, and request layers, move legacy helpers and runtime modules out of the final architecture, and add focused specs for the new service boundaries.
1 parent e0692d7 commit b61602d

69 files changed

Lines changed: 3900 additions & 670 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

app/health_check.rb

Lines changed: 0 additions & 68 deletions
This file was deleted.

app/http_cache.rb

Lines changed: 0 additions & 37 deletions
This file was deleted.

app/local_config.rb

Lines changed: 0 additions & 67 deletions
This file was deleted.

app/web/api/v1/contract.rb

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# frozen_string_literal: true
2+
3+
module Html2rss
4+
module Web
5+
module Api
6+
module V1
7+
module Contract
8+
CODES = {
9+
unauthorized: Html2rss::Web::UnauthorizedError::CODE,
10+
forbidden: Html2rss::Web::ForbiddenError::CODE,
11+
internal_server_error: Html2rss::Web::InternalServerError::CODE
12+
}.freeze
13+
14+
MESSAGES = {
15+
auto_source_disabled: 'Auto source feature is disabled',
16+
health_check_failed: 'Health check failed'
17+
}.freeze
18+
end
19+
end
20+
end
21+
end
22+
end

app/web/api/v1/create_feed.rb

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
# frozen_string_literal: true
2+
3+
require 'time'
4+
require 'json'
5+
6+
module Html2rss
7+
module Web
8+
module Api
9+
module V1
10+
##
11+
# Creates stable feed records from authenticated API requests.
12+
module CreateFeed # rubocop:disable Metrics/ModuleLength
13+
FEED_ATTRIBUTE_KEYS =
14+
%i[id name url strategy feed_token public_url json_public_url created_at updated_at].freeze
15+
class << self # rubocop:disable Metrics/ClassLength
16+
# Creates a feed and returns a normalized API success payload.
17+
#
18+
# @param request [Rack::Request] HTTP request with auth context.
19+
# @return [Hash{Symbol=>Object}] API response payload.
20+
def call(request)
21+
params, feed_data = build_feed_from_request(request)
22+
emit_create_success(params)
23+
Response.success(response: request.response,
24+
status: 201,
25+
data: { feed: feed_attributes(feed_data) },
26+
meta: { created: true })
27+
rescue StandardError => error
28+
emit_create_failure(error)
29+
raise
30+
end
31+
32+
private
33+
34+
# @return [void]
35+
def ensure_auto_source_enabled!
36+
raise Html2rss::Web::ForbiddenError, Contract::MESSAGES[:auto_source_disabled] unless AutoSource.enabled?
37+
end
38+
39+
# @param request [Rack::Request]
40+
# @return [Hash]
41+
def require_account(request)
42+
account = Auth.authenticate(request)
43+
raise Html2rss::Web::UnauthorizedError, 'Authentication required' unless account
44+
45+
account
46+
end
47+
48+
# @param params [Hash]
49+
# @param account [Hash]
50+
# @return [Html2rss::Web::Api::V1::FeedMetadata::CreateParams]
51+
def build_create_params(params, account)
52+
url = validated_url(params['url'], account)
53+
FeedMetadata::CreateParams.new(
54+
url: url,
55+
name: FeedMetadata.site_title_for(url),
56+
strategy: normalize_strategy(params['strategy'])
57+
)
58+
end
59+
60+
# @param raw_url [String, nil]
61+
# @param account [Hash]
62+
# @return [String]
63+
def validated_url(raw_url, account)
64+
url = raw_url.to_s.strip
65+
raise Html2rss::Web::BadRequestError, 'URL parameter is required' if url.empty?
66+
raise Html2rss::Web::BadRequestError, 'Invalid URL format' unless UrlValidator.valid_url?(url)
67+
unless UrlValidator.url_allowed?(account, url)
68+
raise Html2rss::Web::ForbiddenError, 'URL not allowed for this account'
69+
end
70+
71+
url
72+
end
73+
74+
# @param raw_strategy [String, nil]
75+
# @return [String]
76+
def normalize_strategy(raw_strategy)
77+
strategy = raw_strategy.to_s.strip
78+
strategy = default_strategy if strategy.empty?
79+
80+
raise Html2rss::Web::BadRequestError, 'Unsupported strategy' unless supported_strategy?(strategy)
81+
82+
strategy
83+
end
84+
85+
# @return [Array<String>] supported strategy identifiers.
86+
def supported_strategies
87+
Html2rss::RequestService.strategy_names.map(&:to_s)
88+
end
89+
90+
# @param strategy [String]
91+
# @return [Boolean]
92+
def supported_strategy?(strategy)
93+
supported_strategies.include?(strategy)
94+
end
95+
96+
# @return [String] default strategy identifier.
97+
def default_strategy
98+
Html2rss::RequestService.default_strategy_name.to_s
99+
end
100+
101+
# @param feed_data [Hash, Html2rss::Web::Api::V1::FeedMetadata::Metadata]
102+
# @return [Hash{Symbol=>Object}]
103+
def feed_attributes(feed_data)
104+
timestamp = Time.now.iso8601
105+
typed_feed = feed_metadata(feed_data)
106+
typed_feed_attributes(typed_feed, timestamp).slice(*FEED_ATTRIBUTE_KEYS)
107+
end
108+
109+
# @param request [Rack::Request]
110+
# @return [Hash]
111+
def request_params(request)
112+
return request.params unless json_request?(request)
113+
114+
raw_body = request.body.read
115+
request.body.rewind
116+
return request.params if raw_body.strip.empty?
117+
118+
parsed = JSON.parse(raw_body)
119+
raise Html2rss::Web::BadRequestError, 'Invalid JSON payload' unless parsed.is_a?(Hash)
120+
121+
request.params.merge(parsed)
122+
rescue JSON::ParserError
123+
raise Html2rss::Web::BadRequestError, 'Invalid JSON payload'
124+
end
125+
126+
# @param request [Rack::Request]
127+
# @return [Boolean]
128+
def json_request?(request)
129+
content_type = request.env['CONTENT_TYPE'].to_s
130+
content_type.include?('application/json')
131+
end
132+
133+
# @param request [Rack::Request]
134+
# @return [Array<(Html2rss::Web::Api::V1::FeedMetadata::CreateParams, Object)>]
135+
def build_feed_from_request(request)
136+
account = require_account(request)
137+
ensure_auto_source_enabled!
138+
params = build_create_params(request_params(request), account)
139+
140+
feed_data = AutoSource.create_stable_feed(params.name, params.url, account, params.strategy)
141+
raise Html2rss::Web::InternalServerError, 'Failed to create feed' unless feed_data
142+
143+
[params, feed_data]
144+
end
145+
146+
# @param params [Html2rss::Web::Api::V1::FeedMetadata::CreateParams]
147+
# @return [void]
148+
def emit_create_success(params)
149+
Observability.emit(
150+
event_name: 'feed.create',
151+
outcome: 'success',
152+
details: { strategy: params.strategy, url: params.url },
153+
level: :info
154+
)
155+
end
156+
157+
# @param error [StandardError]
158+
# @return [void]
159+
def emit_create_failure(error)
160+
Observability.emit(
161+
event_name: 'feed.create',
162+
outcome: 'failure',
163+
details: { error_class: error.class.name, error_message: error.message },
164+
level: :warn
165+
)
166+
end
167+
168+
# @param feed_data [Hash, Html2rss::Web::Api::V1::FeedMetadata::Metadata]
169+
# @return [Html2rss::Web::Api::V1::FeedMetadata::Metadata]
170+
def feed_metadata(feed_data)
171+
return feed_data if feed_data.is_a?(FeedMetadata::Metadata)
172+
173+
FeedMetadata::Metadata.new(**feed_data)
174+
end
175+
176+
# @param typed_feed [Html2rss::Web::Api::V1::FeedMetadata::Metadata]
177+
# @param timestamp [String]
178+
# @return [Hash{Symbol=>Object}]
179+
def typed_feed_attributes(typed_feed, timestamp)
180+
typed_feed.to_h.merge(created_at: timestamp, updated_at: timestamp)
181+
end
182+
end
183+
end
184+
end
185+
end
186+
end
187+
end

0 commit comments

Comments
 (0)