Skip to content

Commit ba94bfb

Browse files
committed
Expose json feed URLs and tighten recovery flow
1 parent 3439bd0 commit ba94bfb

17 files changed

Lines changed: 205 additions & 69 deletions

app/api/v1/feeds.rb

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# frozen_string_literal: true
22

3+
require 'html2rss/url'
4+
35
require_relative 'contract'
46
require_relative 'feeds/create_feed'
57
require_relative 'feeds/show_feed'
@@ -33,7 +35,9 @@ def create(request)
3335
# @param url [String]
3436
# @return [String, nil]
3537
def extract_site_title(url)
36-
CreateFeed.extract_site_title(url)
38+
Html2rss::Url.for_channel(url).channel_titleized
39+
rescue StandardError
40+
nil
3741
end
3842
end
3943
end

app/api/v1/feeds/create_feed.rb

Lines changed: 16 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
require 'time'
44
require 'json'
5-
require 'html2rss/url'
65

76
require_relative '../../../auth'
87
require_relative '../../../auto_source'
@@ -18,12 +17,11 @@ module Api
1817
module V1
1918
module Feeds
2019
##
21-
# Creates stable feed records from authenticated API requests.
22-
#
23-
# The implementation intentionally keeps parsing, authorization, and
24-
# normalization in a single boundary object so callers can rely on one
25-
# predictable contract instead of coordinating multiple services.
20+
# Creates stable feed records from authenticated API requests with one predictable boundary contract.
2621
module CreateFeed
22+
FEED_ATTRIBUTE_KEYS =
23+
%i[id name url strategy feed_token public_url json_public_url created_at updated_at].freeze
24+
2725
class << self
2826
# Creates a feed and returns a normalized API success payload.
2927
#
@@ -41,41 +39,19 @@ def call(request)
4139
raise
4240
end
4341

44-
# Extracts a best-effort human-readable title from the URL.
45-
#
46-
# @param url [String] target source URL.
47-
# @return [String, nil] inferred title or nil when unavailable.
48-
def extract_site_title(url)
49-
Html2rss::Url.for_channel(url).channel_titleized
50-
rescue StandardError
51-
nil
52-
end
53-
5442
private
5543

56-
# Enforces feature availability at the API edge to fail fast.
57-
#
58-
# @return [void]
5944
def ensure_auto_source_enabled!
6045
raise ForbiddenError, Contract::MESSAGES[:auto_source_disabled] unless AutoSource.enabled?
6146
end
6247

63-
# Resolves the authenticated account from the request.
64-
#
65-
# @param request [Rack::Request]
66-
# @return [Hash{Symbol=>Object}] authenticated account attributes.
6748
def require_account(request)
6849
account = Auth.authenticate(request)
6950
raise UnauthorizedError, 'Authentication required' unless account
7051

7152
account
7253
end
7354

74-
# Validates and normalizes feed creation parameters.
75-
#
76-
# @param params [Hash{String=>Object}] merged request parameters.
77-
# @param account [Hash{Symbol=>Object}] authenticated account.
78-
# @return [Html2rss::Web::BoundaryModels::FeedCreateParams]
7955
def build_create_params(params, account)
8056
url = params['url'].to_s.strip
8157
raise BadRequestError, 'URL parameter is required' if url.empty?
@@ -89,10 +65,6 @@ def build_create_params(params, account)
8965
)
9066
end
9167

92-
# Normalizes a strategy value while preserving a default path.
93-
#
94-
# @param raw_strategy [String, nil]
95-
# @return [String]
9668
def normalize_strategy(raw_strategy)
9769
strategy = raw_strategy.to_s.strip
9870
strategy = default_strategy if strategy.empty?
@@ -112,24 +84,13 @@ def default_strategy
11284
Html2rss::RequestService.default_strategy_name.to_s
11385
end
11486

115-
# Shapes feed attributes into the stable API schema.
116-
#
117-
# @param feed_data [Html2rss::Web::BoundaryModels::FeedMetadata, Hash{Symbol=>Object}] feed record.
118-
# @return [Hash{Symbol=>Object}] response-safe feed attributes.
11987
def feed_attributes(feed_data)
120-
typed_feed = feed_data.is_a?(BoundaryModels::FeedMetadata) ? feed_data : BoundaryModels::FeedMetadata.new(**feed_data)
12188
timestamp = Time.now.iso8601
89+
typed_feed = feed_metadata(feed_data)
12290

123-
typed_feed.to_h.merge(
124-
created_at: timestamp,
125-
updated_at: timestamp
126-
).slice(:id, :name, :url, :strategy, :feed_token, :public_url, :created_at, :updated_at)
91+
typed_feed_attributes(typed_feed, timestamp).slice(*FEED_ATTRIBUTE_KEYS)
12792
end
12893

129-
# Parses params with optional JSON body override.
130-
#
131-
# @param request [Rack::Request]
132-
# @return [Hash{String=>Object}] merged request params.
13394
def request_params(request)
13495
return request.params unless json_request?(request)
13596

@@ -145,15 +106,11 @@ def request_params(request)
145106
raise BadRequestError, 'Invalid JSON payload'
146107
end
147108

148-
# @param request [Rack::Request]
149-
# @return [Boolean] whether request body should be parsed as JSON.
150109
def json_request?(request)
151110
content_type = request.env['CONTENT_TYPE'].to_s
152111
content_type.include?('application/json')
153112
end
154113

155-
# @param request [Rack::Request]
156-
# @return [Array<(Html2rss::Web::BoundaryModels::FeedCreateParams, Object)>]
157114
def build_feed_from_request(request)
158115
account = require_account(request)
159116
ensure_auto_source_enabled!
@@ -165,8 +122,6 @@ def build_feed_from_request(request)
165122
[params, feed_data]
166123
end
167124

168-
# @param params [Html2rss::Web::BoundaryModels::FeedCreateParams]
169-
# @return [void]
170125
def emit_create_success(params)
171126
Observability.emit(
172127
event_name: 'feed.create',
@@ -176,8 +131,6 @@ def emit_create_success(params)
176131
)
177132
end
178133

179-
# @param error [StandardError]
180-
# @return [void]
181134
def emit_create_failure(error)
182135
Observability.emit(
183136
event_name: 'feed.create',
@@ -186,6 +139,16 @@ def emit_create_failure(error)
186139
level: :warn
187140
)
188141
end
142+
143+
def feed_metadata(feed_data)
144+
return feed_data if feed_data.is_a?(BoundaryModels::FeedMetadata)
145+
146+
BoundaryModels::FeedMetadata.new(**feed_data)
147+
end
148+
149+
def typed_feed_attributes(typed_feed, timestamp)
150+
typed_feed.to_h.merge(created_at: timestamp, updated_at: timestamp)
151+
end
189152
end
190153
end
191154
end

app/auto_source.rb

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,18 +89,29 @@ def generate_feed_id(username, url, token)
8989
# @param identifiers [Hash{Symbol=>String}]
9090
# @return [Html2rss::Web::BoundaryModels::FeedMetadata]
9191
def build_feed_data(name, url, token_data, strategy, identifiers)
92-
public_url = "/api/v1/feeds/#{identifiers[:feed_token]}"
93-
9492
BoundaryModels::FeedMetadata.new(
9593
id: identifiers[:feed_id],
9694
name: name,
9795
url: url,
9896
username: token_data[:username],
9997
strategy: strategy,
10098
feed_token: identifiers[:feed_token],
101-
public_url: public_url
99+
public_url: feed_public_url(identifiers[:feed_token]),
100+
json_public_url: feed_json_public_url(identifiers[:feed_token])
102101
)
103102
end
103+
104+
# @param feed_token [String]
105+
# @return [String]
106+
def feed_public_url(feed_token)
107+
"/api/v1/feeds/#{feed_token}"
108+
end
109+
110+
# @param feed_token [String]
111+
# @return [String]
112+
def feed_json_public_url(feed_token)
113+
"#{feed_public_url(feed_token)}.json"
114+
end
104115
end
105116
end
106117
end

app/boundary_models.rb

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ def to_h
1616

1717
##
1818
# Feed metadata contract used between feed services and API responses.
19-
FeedMetadata = Data.define(:id, :name, :url, :username, :strategy, :feed_token, :public_url) do
19+
FeedMetadata = Data.define(:id, :name, :url, :username, :strategy, :feed_token, :public_url, :json_public_url) do
2020
# @return [Hash{Symbol=>Object}]
2121
def to_h
2222
{
@@ -26,7 +26,8 @@ def to_h
2626
username: username,
2727
strategy: strategy,
2828
feed_token: feed_token,
29-
public_url: public_url
29+
public_url: public_url,
30+
json_public_url: json_public_url
3031
}
3132
end
3233
end

frontend/src/__tests__/App.contract.test.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,11 @@ describe('App contract', () => {
2626
url: body.url,
2727
feed_token: 'generated-token',
2828
public_url: '/api/v1/feeds/generated-token',
29+
json_public_url: '/api/v1/feeds/generated-token.json',
2930
})
3031
);
3132
}),
32-
http.get('/api/v1/feeds/generated-token', ({ request }) => {
33+
http.get('/api/v1/feeds/generated-token.json', ({ request }) => {
3334
expect(request.headers.get('accept')).toBe('application/feed+json');
3435

3536
return HttpResponse.json(
@@ -57,6 +58,10 @@ describe('App contract', () => {
5758
expect(screen.getByLabelText('Feed URL')).toBeInTheDocument();
5859
expect(screen.getByRole('button', { name: 'Copy feed URL' })).toBeInTheDocument();
5960
expect(screen.getByRole('link', { name: 'Open feed' })).toBeInTheDocument();
61+
expect(screen.getByRole('link', { name: 'JSON Feed' })).toHaveAttribute(
62+
'href',
63+
'http://localhost:3000/api/v1/feeds/generated-token.json'
64+
);
6065
expect(screen.getByRole('button', { name: 'Create another feed' })).toBeInTheDocument();
6166
expect(screen.getByText('Feed preview')).toBeInTheDocument();
6267
expect(screen.getByText('Contract Item')).toBeInTheDocument();

frontend/src/__tests__/App.test.tsx

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ describe('App', () => {
3333
const mockSaveToken = vi.fn();
3434
const mockClearToken = vi.fn();
3535
const mockConvertFeed = vi.fn();
36+
const mockClearConversionError = vi.fn();
3637
const mockClearResult = vi.fn();
3738

3839
beforeEach(() => {
@@ -70,6 +71,7 @@ describe('App', () => {
7071
result: null,
7172
error: null,
7273
convertFeed: mockConvertFeed,
74+
clearError: mockClearConversionError,
7375
clearResult: mockClearResult,
7476
});
7577

@@ -131,9 +133,11 @@ describe('App', () => {
131133
strategy: 'ssrf_filter',
132134
feed_token: 'example-token',
133135
public_url: '/api/v1/feeds/example-token',
136+
json_public_url: '/api/v1/feeds/example-token.json',
134137
},
135138
error: null,
136139
convertFeed: mockConvertFeed,
140+
clearError: mockClearConversionError,
137141
clearResult: mockClearResult,
138142
});
139143

@@ -198,6 +202,59 @@ describe('App', () => {
198202
});
199203
});
200204

205+
it('reopens the token prompt when a saved token is rejected', async () => {
206+
mockUseAccessToken.mockReturnValue({
207+
token: 'saved-token',
208+
hasToken: true,
209+
saveToken: mockSaveToken,
210+
clearToken: mockClearToken,
211+
isLoading: false,
212+
error: null,
213+
});
214+
mockConvertFeed.mockRejectedValueOnce(new Error('Unauthorized'));
215+
216+
render(<App />);
217+
218+
fireEvent.input(screen.getByLabelText('Page URL'), {
219+
target: { value: 'https://example.com/articles' },
220+
});
221+
fireEvent.click(screen.getByRole('button', { name: 'Generate feed URL' }));
222+
223+
await waitFor(() => {
224+
expect(screen.getByText('Add access token')).toBeInTheDocument();
225+
expect(
226+
screen.getByText('Access token was rejected. Paste a valid token to continue.')
227+
).toBeInTheDocument();
228+
expect(mockClearToken).toHaveBeenCalled();
229+
expect(mockClearConversionError).toHaveBeenCalled();
230+
});
231+
});
232+
233+
it('clears stale conversion error when backing out of token recovery', async () => {
234+
mockUseAccessToken.mockReturnValue({
235+
token: 'saved-token',
236+
hasToken: true,
237+
saveToken: mockSaveToken,
238+
clearToken: mockClearToken,
239+
isLoading: false,
240+
error: null,
241+
});
242+
mockConvertFeed.mockRejectedValueOnce(new Error('Unauthorized'));
243+
244+
render(<App />);
245+
246+
fireEvent.input(screen.getByLabelText('Page URL'), {
247+
target: { value: 'https://example.com/articles' },
248+
});
249+
fireEvent.click(screen.getByRole('button', { name: 'Generate feed URL' }));
250+
251+
await screen.findByText('Access token was rejected. Paste a valid token to continue.');
252+
fireEvent.click(screen.getByRole('button', { name: 'Back' }));
253+
254+
expect(screen.queryByText('Feed generation failed')).not.toBeInTheDocument();
255+
expect(screen.queryByText('Unauthorized')).not.toBeInTheDocument();
256+
});
257+
201258
it('submits the token prompt with Enter', async () => {
202259
render(<App />);
203260

frontend/src/__tests__/ResultDisplay.test.tsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ describe('ResultDisplay', () => {
1212
strategy: 'ssrf_filter',
1313
feed_token: 'test-feed-token',
1414
public_url: 'https://example.com/feed.xml',
15+
json_public_url: 'https://example.com/feed.json',
1516
};
1617

1718
beforeEach(() => {
@@ -34,16 +35,33 @@ describe('ResultDisplay', () => {
3435
expect(screen.getByText('Test Feed')).toBeInTheDocument();
3536
expect(screen.getByRole('button', { name: 'Copy feed URL' })).toBeInTheDocument();
3637
expect(screen.getByRole('link', { name: 'Open feed' })).toBeInTheDocument();
38+
expect(screen.getByRole('link', { name: 'JSON Feed' })).toHaveAttribute(
39+
'href',
40+
'https://example.com/feed.json'
41+
);
3742
await waitFor(() => {
3843
expect(screen.getByText('Item One')).toBeInTheDocument();
3944
expect(screen.getByText(/points by canpan/i)).toBeInTheDocument();
4045
expect(screen.getByText('Item Two')).toBeInTheDocument();
4146
});
42-
expect(window.fetch).toHaveBeenCalledWith('https://example.com/feed.xml', {
47+
expect(window.fetch).toHaveBeenCalledWith('https://example.com/feed.json', {
4348
headers: { Accept: 'application/feed+json' },
4449
});
4550
});
4651

52+
it('surfaces preview fetch failures as a result-state message', async () => {
53+
vi.mocked(window.fetch).mockResolvedValueOnce({
54+
ok: false,
55+
json: async () => ({}),
56+
} as Response);
57+
58+
render(<ResultDisplay result={mockResult} onCreateAnother={mockOnCreateAnother} />);
59+
60+
await waitFor(() => {
61+
expect(screen.getByText('Preview unavailable right now.')).toBeInTheDocument();
62+
});
63+
});
64+
4765
it('calls onCreateAnother when the reset button is clicked', () => {
4866
render(<ResultDisplay result={mockResult} onCreateAnother={mockOnCreateAnother} />);
4967

frontend/src/__tests__/mocks/server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export interface FeedResponseOverrides {
4949
strategy?: string;
5050
feed_token?: string;
5151
public_url?: string;
52+
json_public_url?: string;
5253
created_at?: string;
5354
updated_at?: string;
5455
}
@@ -66,6 +67,7 @@ export function buildFeedResponse(overrides: FeedResponseOverrides = {}) {
6667
strategy: overrides.strategy ?? 'ssrf_filter',
6768
feed_token: overrides.feed_token ?? 'example-token',
6869
public_url: overrides.public_url ?? '/api/v1/feeds/example-token',
70+
json_public_url: overrides.json_public_url ?? '/api/v1/feeds/example-token.json',
6971
created_at: timestamp,
7072
updated_at: overrides.updated_at ?? timestamp,
7173
},

0 commit comments

Comments
 (0)