Skip to content

Commit 0b59322

Browse files
committed
feat(api): structured conversion failure and feed status contracts
1 parent 76570ba commit 0b59322

23 files changed

Lines changed: 684 additions & 107 deletions

app/web/api/v1/contract.rb

Lines changed: 142 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,158 @@ module Html2rss
44
module Web
55
module Api
66
module V1
7-
module Contract
7+
##
8+
# Shared API v1 contract constants and payload builders.
9+
module Contract # rubocop:disable Metrics/ModuleLength
810
CODES = {
911
unauthorized: Html2rss::Web::UnauthorizedError::CODE,
1012
forbidden: Html2rss::Web::ForbiddenError::CODE,
1113
internal_server_error: Html2rss::Web::InternalServerError::CODE
1214
}.freeze
1315

16+
ERROR_KINDS = {
17+
auth: 'auth',
18+
input: 'input',
19+
network: 'network',
20+
server: 'server'
21+
}.freeze
22+
23+
NEXT_ACTIONS = {
24+
enter_token: 'enter_token',
25+
correct_input: 'correct_input',
26+
retry: 'retry',
27+
wait: 'wait',
28+
none: 'none'
29+
}.freeze
30+
31+
RETRY_ACTIONS = {
32+
alternate: 'alternate',
33+
primary: 'primary',
34+
none: 'none'
35+
}.freeze
36+
37+
READINESS_PHASES = {
38+
link_created: 'link_created',
39+
feed_ready: 'feed_ready',
40+
feed_not_ready_yet: 'feed_not_ready_yet',
41+
preview_unavailable: 'preview_unavailable'
42+
}.freeze
43+
44+
PREVIEW_STATUSES = {
45+
pending: 'pending',
46+
ready: 'ready',
47+
degraded: 'degraded',
48+
unavailable: 'unavailable'
49+
}.freeze
50+
1451
MESSAGES = {
1552
auto_source_disabled: 'Auto source feature is disabled',
1653
health_check_failed: 'Health check failed'
1754
}.freeze
55+
56+
class << self
57+
# Builds the structured API error envelope used by JSON API routes.
58+
#
59+
# @param error [StandardError]
60+
# @return [Hash{Symbol=>Object}] structured API error details.
61+
def failure_payload(error)
62+
metadata = failure_metadata(error)
63+
base = {
64+
message: client_message_for(error),
65+
code: error_code_for(error),
66+
kind: metadata[:kind],
67+
retryable: metadata[:retryable],
68+
next_action: metadata[:next_action],
69+
retry_action: metadata[:retry_action]
70+
}
71+
metadata[:next_strategy] ? base.merge(next_strategy: metadata[:next_strategy]) : base
72+
end
73+
74+
# Builds a warning entry for readiness/status responses.
75+
#
76+
# @param code [String]
77+
# @param message [String]
78+
# @param retryable [Boolean]
79+
# @param next_action [String]
80+
# @return [Hash{Symbol=>Object}] structured warning payload.
81+
def warning(code:, message:, retryable:, next_action:)
82+
{
83+
code: code,
84+
message: message,
85+
retryable: retryable,
86+
next_action: next_action
87+
}
88+
end
89+
90+
private
91+
92+
# @param error [StandardError]
93+
# @return [Hash{Symbol=>Object}]
94+
def failure_metadata(error)
95+
case error
96+
when Html2rss::Web::AutoSourceDisabledError, Html2rss::Web::HealthCheckFailedError
97+
non_retryable_server_failure_metadata
98+
when Html2rss::Web::UnauthorizedError then auth_failure_metadata
99+
when Html2rss::Web::BadRequestError, Html2rss::Web::ForbiddenError then input_failure_metadata
100+
else
101+
generic_failure_metadata(error)
102+
end
103+
end
104+
105+
# @param error [StandardError]
106+
# @return [Hash{Symbol=>Object}]
107+
def generic_failure_metadata(error)
108+
kind = Html2rss::Web::ErrorClassification.network_error?(error) ? :network : :server
109+
{
110+
kind: ERROR_KINDS[kind],
111+
retryable: true,
112+
next_action: NEXT_ACTIONS[:retry],
113+
retry_action: RETRY_ACTIONS[:primary]
114+
}
115+
end
116+
117+
# @return [Hash{Symbol=>Object}]
118+
def auth_failure_metadata
119+
{
120+
kind: ERROR_KINDS[:auth],
121+
retryable: false,
122+
next_action: NEXT_ACTIONS[:enter_token],
123+
retry_action: RETRY_ACTIONS[:none]
124+
}
125+
end
126+
127+
# @return [Hash{Symbol=>Object}]
128+
def input_failure_metadata
129+
{
130+
kind: ERROR_KINDS[:input],
131+
retryable: false,
132+
next_action: NEXT_ACTIONS[:correct_input],
133+
retry_action: RETRY_ACTIONS[:none]
134+
}
135+
end
136+
137+
# @return [Hash{Symbol=>Object}]
138+
def non_retryable_server_failure_metadata
139+
{
140+
kind: ERROR_KINDS[:server],
141+
retryable: false,
142+
next_action: NEXT_ACTIONS[:none],
143+
retry_action: RETRY_ACTIONS[:none]
144+
}
145+
end
146+
147+
# @param error [StandardError]
148+
# @return [String]
149+
def client_message_for(error)
150+
error.is_a?(Html2rss::Web::HttpError) ? error.message : Html2rss::Web::HttpError::DEFAULT_MESSAGE
151+
end
152+
153+
# @param error [StandardError]
154+
# @return [String]
155+
def error_code_for(error)
156+
error.respond_to?(:code) ? error.code : CODES[:internal_server_error]
157+
end
158+
end
18159
end
19160
end
20161
end

app/web/api/v1/create_feed.rb

Lines changed: 10 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -11,18 +11,21 @@ module V1
1111
# Creates stable feed records from authenticated API requests.
1212
module CreateFeed # rubocop:disable Metrics/ModuleLength
1313
FEED_ATTRIBUTE_KEYS =
14-
%i[id name url strategy feed_token public_url json_public_url created_at updated_at].freeze
14+
%i[id name url feed_token public_url json_public_url created_at updated_at].freeze
1515
class << self # rubocop:disable Metrics/ClassLength
1616
# Creates a feed and returns a normalized API success payload.
1717
#
1818
# @param request [Rack::Request] HTTP request with auth context.
1919
# @return [Hash{Symbol=>Object}] API response payload.
20-
def call(request)
20+
def call(request) # rubocop:disable Metrics/MethodLength
2121
params, feed_data = build_feed_from_request(request)
2222
emit_create_success(params)
2323
Response.success(response: request.response,
2424
status: 201,
25-
data: { feed: feed_attributes(feed_data) },
25+
data: {
26+
feed: feed_attributes(feed_data),
27+
conversion: FeedStatus.initial_conversion
28+
},
2629
meta: { created: true })
2730
rescue StandardError => error
2831
emit_create_failure(error)
@@ -33,7 +36,7 @@ def call(request)
3336

3437
# @return [void]
3538
def ensure_auto_source_enabled!
36-
raise Html2rss::Web::ForbiddenError, Contract::MESSAGES[:auto_source_disabled] unless AutoSource.enabled?
39+
raise Html2rss::Web::AutoSourceDisabledError unless AutoSource.enabled?
3740
end
3841

3942
# @param request [Rack::Request]
@@ -52,8 +55,7 @@ def build_create_params(params, account)
5255
url = validated_url(params['url'], account)
5356
FeedMetadata::CreateParams.new(
5457
url: url,
55-
name: FeedMetadata.site_title_for(url),
56-
strategy: normalize_strategy(params['strategy'])
58+
name: FeedMetadata.site_title_for(url)
5759
)
5860
end
5961

@@ -101,33 +103,6 @@ def hostname_input?(url)
101103
}ix.match?(url)
102104
end
103105

104-
# @param raw_strategy [String, nil]
105-
# @return [String]
106-
def normalize_strategy(raw_strategy)
107-
strategy = raw_strategy.to_s.strip
108-
strategy = default_strategy if strategy.empty?
109-
110-
raise Html2rss::Web::BadRequestError, 'Unsupported strategy' unless supported_strategy?(strategy)
111-
112-
strategy
113-
end
114-
115-
# @return [Array<String>] supported strategy identifiers.
116-
def supported_strategies
117-
Html2rss::RequestService.strategy_names.map(&:to_s)
118-
end
119-
120-
# @param strategy [String]
121-
# @return [Boolean]
122-
def supported_strategy?(strategy)
123-
supported_strategies.include?(strategy)
124-
end
125-
126-
# @return [String] default strategy identifier.
127-
def default_strategy
128-
Html2rss::RequestService.default_strategy_name.to_s
129-
end
130-
131106
# @param feed_data [Hash, Html2rss::Web::Api::V1::FeedMetadata::Metadata]
132107
# @return [Hash{Symbol=>Object}]
133108
def feed_attributes(feed_data)
@@ -167,7 +142,7 @@ def build_feed_from_request(request)
167142
ensure_auto_source_enabled!
168143
params = build_create_params(request_params(request), account)
169144

170-
feed_data = AutoSource.create_stable_feed(params.name, params.url, account, params.strategy)
145+
feed_data = AutoSource.create_stable_feed(params.name, params.url, account)
171146
raise Html2rss::Web::InternalServerError, 'Failed to create feed' unless feed_data
172147

173148
[params, feed_data]
@@ -179,7 +154,7 @@ def emit_create_success(params)
179154
Observability.emit(
180155
event_name: 'feed.create',
181156
outcome: 'success',
182-
details: { strategy: params.strategy, url: params.url },
157+
details: { url: params.url },
183158
level: :info
184159
)
185160
end

app/web/api/v1/feed_metadata.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,10 +64,10 @@ def json_public_url(feed_token)
6464

6565
##
6666
# Feed create parameters contract.
67-
CreateParams = Data.define(:url, :name, :strategy) do
67+
CreateParams = Data.define(:url, :name) do
6868
# @return [Hash{Symbol=>Object}]
6969
def to_h
70-
{ url: url, name: name, strategy: strategy }
70+
{ url: url, name: name }
7171
end
7272
end
7373

0 commit comments

Comments
 (0)