Skip to content

Commit 1a691ad

Browse files
committed
refactor feed routing and rendering slice
1 parent 0a0af85 commit 1a691ad

32 files changed

Lines changed: 507 additions & 700 deletions

app.rb

Lines changed: 2 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,6 @@ class App < Roda
2929
</html>
3030
HTML
3131
def self.development? = EnvironmentValidator.development?
32-
class << self
33-
# @return [Html2rss::Web::AppContext::Context]
34-
def context
35-
AppContext.build
36-
end
37-
end
3832

3933
def development? = self.class.development?
4034
EnvironmentValidator.validate_environment!
@@ -99,19 +93,10 @@ def development? = self.class.development?
9993
end
10094

10195
route do |r|
102-
context = self.class.context
10396
r.public
10497

105-
context.routes_api_v1.call(r, context: context) ||
106-
context.routes_static.call(r,
107-
feed_handler: lambda { |router_ctx, feed_name|
108-
Http::FeedRouteHandler.call(
109-
context: context,
110-
router: router_ctx,
111-
feed_name: feed_name
112-
)
113-
},
114-
index_renderer: ->(router_ctx) { render_index_page(router_ctx) })
98+
Routes::ApiV1.call(r) ||
99+
Routes::FeedPages.call(r, index_renderer: ->(router_ctx) { render_index_page(router_ctx) })
115100
end
116101

117102
private

app/api/v1/create_feed.rb

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

app/api/v1/feed_metadata.rb

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# frozen_string_literal: true
2+
3+
require 'html2rss/url'
4+
5+
module Html2rss
6+
module Web
7+
module Api
8+
module V1
9+
##
10+
# Immutable contracts for feed creation and API serialization.
11+
module FeedMetadata
12+
class << self
13+
# @param url [String]
14+
# @return [String, nil]
15+
def site_title_for(url)
16+
Html2rss::Url.for_channel(url).channel_titleized
17+
rescue StandardError
18+
nil
19+
end
20+
end
21+
22+
##
23+
# Feed create parameters contract.
24+
CreateParams = Data.define(:url, :name, :strategy) do
25+
# @return [Hash{Symbol=>Object}]
26+
def to_h
27+
{ url: url, name: name, strategy: strategy }
28+
end
29+
end
30+
31+
##
32+
# Feed metadata contract used between creation services and API responses.
33+
Metadata = Data.define(:id, :name, :url, :username, :strategy, :feed_token, :public_url, :json_public_url) do
34+
# @return [Hash{Symbol=>Object}]
35+
def to_h
36+
{
37+
id: id,
38+
name: name,
39+
url: url,
40+
username: username,
41+
strategy: strategy,
42+
feed_token: feed_token,
43+
public_url: public_url,
44+
json_public_url: json_public_url
45+
}
46+
end
47+
end
48+
end
49+
end
50+
end
51+
end
52+
end

app/api/v1/feeds.rb

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

0 commit comments

Comments
 (0)