2 files changed,
241 insertions(+),
12 deletions(-)
Author:
Oleksandr Smirnov
olexsmir@gmail.com
Committed at:
2026-04-22 20:13:38 +0300
Authored at:
2026-04-20 14:46:02 +0300
Change ID:
skqotspuvoqsnksolkmuxymnowqsvqnu
Parent:
d1f2b81
jump to
| M | app/atom.go |
| A | app/atom_test.go |
M
app/atom.go
··· 1 1 package app 2 2 3 3 import ( 4 + "bytes" 5 + "crypto/sha1" 4 6 "encoding/xml" 7 + "fmt" 8 + "io" 5 9 "net/http" 6 10 "time" 7 11 ) 8 12 9 13 type AtomFeed struct { 10 - XMLName xml.Name `xml:"feed"` 11 - XMLNS string `xml:"xmlns,attr"` 12 - Title string `xml:"title"` 13 - ID string `xml:"id"` 14 - Updated string `xml:"updated"` 15 - Entries []AtomEntry `xml:"entry"` 14 + XMLName xml.Name `xml:"feed"` 15 + XMLNS string `xml:"xmlns,attr"` 16 + Title string `xml:"title"` 17 + ID string `xml:"id"` 18 + Updated string `xml:"updated"` 19 + Subtitle string `xml:"subtitle,omitempty"` 20 + Entries []AtomEntry `xml:"entry"` 16 21 } 17 22 18 23 type AtomEntry struct { ··· 28 33 Value string `xml:",chardata"` 29 34 } 30 35 36 +type FeedEntry struct { 37 + Title string 38 + ID string 39 + Content string 40 + ContentType string // "text" or "html", defaults to "text" 41 + Updated time.Time 42 +} 43 + 44 +type FeedOption func(*AtomFeed) 45 + 46 +func WithFeedSubtitle(subtitle string) FeedOption { 47 + return func(f *AtomFeed) { 48 + f.Subtitle = subtitle 49 + } 50 +} 51 + 52 +func WithFeedUpdated(updated time.Time) FeedOption { 53 + return func(f *AtomFeed) { 54 + if !updated.IsZero() { 55 + f.Updated = updated.Format(time.RFC3339) 56 + } 57 + } 58 +} 59 + 31 60 type FeedBuilder struct { 32 61 feed AtomFeed 33 62 } 34 63 35 -func NewFeed(title, id string) *FeedBuilder { 36 - return &FeedBuilder{feed: AtomFeed{ 64 +func NewFeed(title, id string, opts ...FeedOption) *FeedBuilder { 65 + builder := &FeedBuilder{feed: AtomFeed{ 37 66 XMLNS: "http://www.w3.org/2005/Atom", 38 67 Title: title, 39 68 ID: id, 40 69 Updated: time.Now().Format(time.RFC3339), 41 70 }} 71 + for _, opt := range opts { 72 + opt(&builder.feed) 73 + } 74 + return builder 42 75 } 43 76 44 77 func (f *FeedBuilder) Add(title, id, content string, date time.Time) *FeedBuilder { 45 - f.feed.Entries = append(f.feed.Entries, AtomEntry{ 78 + return f.AddEntry(FeedEntry{ 46 79 Title: title, 47 80 ID: id, 48 - Updated: date.Format(time.RFC3339), 81 + Content: content, 82 + Updated: date, 83 + }) 84 +} 85 + 86 +func (f *FeedBuilder) AddText(title, content string, updated time.Time) *FeedBuilder { 87 + return f.AddEntry(FeedEntry{ 88 + Title: title, 49 89 Content: content, 90 + Updated: updated, 50 91 }) 92 +} 93 + 94 +func (f *FeedBuilder) AddEntry(entry FeedEntry) *FeedBuilder { 95 + if entry.Updated.IsZero() { 96 + entry.Updated = time.Now() 97 + } 98 + if entry.ID == "" { 99 + hash := sha1.Sum(fmt.Appendf(nil, "%s|%s|%s", entry.Title, entry.Content, entry.Updated.Format(time.RFC3339Nano))) 100 + entry.ID = fmt.Sprintf("urn:sha1:%x", hash) 101 + } 102 + 103 + f.feed.Entries = append(f.feed.Entries, AtomEntry{ 104 + Title: entry.Title, 105 + ID: entry.ID, 106 + Updated: entry.Updated.Format(time.RFC3339), 107 + Content: AtomContent{ 108 + Type: contentType, 109 + Value: entry.Content, 110 + }, 111 + }) 112 + 113 + feedUpdated, err := time.Parse(time.RFC3339, f.feed.Updated) 114 + if err != nil || entry.Updated.After(feedUpdated) { 115 + f.feed.Updated = entry.Updated.Format(time.RFC3339) 116 + } 51 117 return f 52 118 } 53 119 54 -func (f *FeedBuilder) Render(w http.ResponseWriter) error { 55 - w.Header().Set("Content-Type", "application/atom+xml") 120 +func (f *FeedBuilder) SetUpdated(updated time.Time) *FeedBuilder { 121 + if !updated.IsZero() { 122 + f.feed.Updated = updated.Format(time.RFC3339) 123 + } 124 + return f 125 +} 126 + 127 +func (f *FeedBuilder) WriteTo(w io.Writer) error { 56 128 enc := xml.NewEncoder(w) 57 129 enc.Indent("", " ") 58 130 return enc.Encode(f.feed) 59 131 } 132 + 133 +func (f *FeedBuilder) Bytes() ([]byte, error) { 134 + var buf bytes.Buffer 135 + if err := f.WriteTo(&buf); err != nil { 136 + return nil, err 137 + } 138 + return buf.Bytes(), nil 139 +} 140 + 141 +func (f *FeedBuilder) Render(w http.ResponseWriter) error { 142 + w.Header().Set("Content-Type", "application/atom+xml; charset=utf-8") 143 + return f.WriteTo(w) 144 +}
A
app/atom_test.go
··· 1 +package app 2 + 3 +import ( 4 + "bytes" 5 + "encoding/xml" 6 + "net/http" 7 + "net/http/httptest" 8 + "strings" 9 + "testing" 10 + "time" 11 + 12 + "olexsmir.xyz/x/is" 13 +) 14 + 15 +func TestFeedBuilderAddEntryDefaults(t *testing.T) { 16 + feed := NewFeed("test", "feed-id") 17 + feed.AddEntry(FeedEntry{Title: "entry", Content: "body"}) 18 + 19 + is.Equal(t, 1, len(feed.f.Entries)) 20 + entry := feed.f.Entries[0] 21 + is.NotEqual(t, "", entry.ID) 22 + is.NotEqual(t, "", entry.Updated) 23 +} 24 + 25 +func TestFeedBuilderBytesAndWriteTo(t *testing.T) { 26 + updated := time.Date(2026, 4, 20, 12, 30, 0, 0, time.UTC) 27 + feed := NewFeed("test", "feed-id", WithFeedSubtitle("subtitle")).AddText("entry", "content", updated) 28 + 29 + raw, err := feed.Bytes() 30 + is.Err(t, err, nil) 31 + if !bytes.Contains(raw, []byte("<subtitle>subtitle</subtitle>")) { 32 + t.Fatalf("expected subtitle in serialized feed") 33 + } 34 + 35 + var parsed AtomFeed 36 + is.Err(t, xml.Unmarshal(raw, &parsed), nil) 37 + is.Equal(t, "test", parsed.Title) 38 +} 39 + 40 +func TestFeedBuilderRender(t *testing.T) { 41 + r := httptest.NewRecorder() 42 + err := NewFeed("test", "feed-id"). 43 + Add("entry", "entry-id", "content", time.Date(2026, 4, 20, 8, 0, 0, 0, time.UTC)). 44 + Render(r) 45 + is.Err(t, err, nil) 46 + 47 + is.Equal(t, http.StatusOK, r.Code) 48 + if got := r.Header().Get("Content-Type"); !strings.Contains(got, "application/atom+xml") { 49 + t.Fatalf("unexpected content type: %q", got) 50 + } 51 +} 52 + 53 +func TestFeedEntryTextContent(t *testing.T) { 54 + feed := NewFeed("test", "feed-id"). 55 + AddEntry(FeedEntry{ 56 + Title: "text entry", 57 + Content: "plain text content", 58 + ContentType: "text", 59 + Updated: time.Date(2026, 4, 20, 12, 0, 0, 0, time.UTC), 60 + }) 61 + 62 + raw, err := feed.Bytes() 63 + is.Err(t, err, nil) 64 + if !strings.Contains(string(raw), `<content type="text">plain text content</content>`) { 65 + t.Fatalf("expected text content with type attribute in serialized feed") 66 + } 67 + 68 + var parsed AtomFeed 69 + is.Err(t, xml.Unmarshal(raw, &parsed), nil) 70 + is.Equal(t, 1, len(parsed.Entries)) 71 + 72 + entry := parsed.Entries[0] 73 + is.Equal(t, "text", entry.Content.Type) 74 + is.Equal(t, "plain text content", entry.Content.Value) 75 +} 76 + 77 +func TestFeedEntryHtmlContent(t *testing.T) { 78 + htmlContent := "<p>Hello <strong>World</strong></p>" 79 + feed := NewFeed("test", "feed-id"). 80 + AddEntry(FeedEntry{ 81 + Title: "html entry", 82 + Content: htmlContent, 83 + ContentType: "html", 84 + Updated: time.Date(2026, 4, 20, 12, 0, 0, 0, time.UTC), 85 + }) 86 + 87 + raw, err := feed.Bytes() 88 + is.Err(t, err, nil) 89 + if !strings.Contains(string(raw), `<content type="html">`) { 90 + t.Fatalf("expected HTML content with type='html' attribute in serialized feed") 91 + } 92 + 93 + var parsed AtomFeed 94 + is.Err(t, xml.Unmarshal(raw, &parsed), nil) 95 + is.Equal(t, 1, len(parsed.Entries)) 96 + 97 + entry := parsed.Entries[0] 98 + is.Equal(t, "html", entry.Content.Type) 99 + is.Equal(t, htmlContent, entry.Content.Value) 100 +} 101 + 102 +func TestFeedMultipleEntriesWithMixedContentTypes(t *testing.T) { 103 + updated := time.Date(2026, 4, 20, 12, 0, 0, 0, time.UTC) 104 + feed := NewFeed("test", "feed-id"). 105 + AddEntry(FeedEntry{ 106 + Title: "text entry", 107 + Content: "plain text", 108 + ContentType: "text", 109 + Updated: updated, 110 + }). 111 + AddEntry(FeedEntry{ 112 + Title: "html entry", 113 + Content: "<p>html content</p>", 114 + ContentType: "html", 115 + Updated: updated, 116 + }). 117 + AddEntry(FeedEntry{ 118 + Title: "default entry", 119 + Content: "default content", 120 + Updated: updated, 121 + }) 122 + 123 + raw, err := feed.Bytes() 124 + is.Err(t, err, nil) 125 + 126 + var parsed AtomFeed 127 + is.Err(t, xml.Unmarshal(raw, &parsed), nil) 128 + is.Equal(t, 3, len(parsed.Entries)) 129 + 130 + tests := []struct { 131 + name string 132 + expectedType string 133 + expectedText string 134 + }{ 135 + {"text entry", "text", "plain text"}, 136 + {"html entry", "html", "<p>html content</p>"}, 137 + {"default entry", "text", "default content"}, 138 + } 139 + for i, tc := range tests { 140 + is.Equal(t, tc.name, parsed.Entries[i].Title) 141 + is.Equal(t, tc.expectedText, parsed.Entries[i].Content.Value) 142 + is.Equal(t, tc.expectedType, parsed.Entries[i].Content.Type) 143 + } 144 +}