From e9747d95774666383b072adf3844e6b5be70772e Mon Sep 17 00:00:00 2001 From: Kevin Stock Date: Sun, 4 Jun 2017 14:48:21 -0700 Subject: [PATCH] Adds support for JSON Feed Version 1 JSON Feed Version 1 specification: https://jsonfeed.org/version/1 --- README.md | 46 +++++++++++++- doc.go | 9 ++- feed.go | 17 +++++ feed_test.go | 65 +++++++++++++++++++ json.go | 174 +++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 305 insertions(+), 6 deletions(-) create mode 100644 json.go diff --git a/README.md b/README.md index e499be8..0df4fc0 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,13 @@ ## gorilla/feeds [![GoDoc](https://godoc.org/github.com/gorilla/feeds?status.svg)](https://godoc.org/github.com/gorilla/feeds) [![Build Status](https://travis-ci.org/gorilla/feeds.png?branch=master)](https://travis-ci.org/gorilla/feeds) -feeds is a web feed generator library for generating RSS and Atom feeds from Go +feeds is a web feed generator library for generating RSS, Atom and JSON feeds from Go applications. ### Goals * Provide a simple interface to create both Atom & RSS 2.0 feeds - * Full support for Atom and RSS2.0 spec elements + * Full support for Atom, RSS2.0, and JSON Feed Version 1 spec elements * Ability to modify particulars for each spec ### Usage @@ -64,7 +64,12 @@ func main() { log.Fatal(err) } - fmt.Println(atom, "\n", rss) + json, err := feed.ToJSON() + if err != nil { + log.Fatal(err) + } + + fmt.Println(atom, "\n", rss, "\n", json) } ``` @@ -136,5 +141,40 @@ Outputs: +{ + "version": "https://jsonfeed.org/version/1", + "title": "jmoiron.net blog", + "home_page_url": "http://jmoiron.net/blog", + "description": "discussion about tech, footie, photos", + "author": { + "name": "Jason Moiron" + }, + "items": [ + { + "id": "", + "url": "http://jmoiron.net/blog/limiting-concurrency-in-go/", + "title": "Limiting Concurrency in Go", + "summary": "A discussion on controlled parallelism in golang", + "date_published": "2013-01-16T03:22:24.530817846-05:00", + "author": { + "name": "Jason Moiron" + } + }, + { + "id": "", + "url": "http://jmoiron.net/blog/logicless-template-redux/", + "title": "Logic-less Template Redux", + "summary": "More thoughts on logicless templates", + "date_published": "2013-01-16T03:22:24.530817846-05:00" + }, + { + "id": "", + "url": "http://jmoiron.net/blog/idiomatic-code-reuse-in-go/", + "title": "Idiomatic Code Reuse in Go", + "summary": "How to use interfaces \u003cem\u003eeffectively\u003c/em\u003e", + "date_published": "2013-01-16T03:22:24.530817846-05:00" + } + ] +} ``` diff --git a/doc.go b/doc.go index 5b005ea..bf7d15e 100644 --- a/doc.go +++ b/doc.go @@ -5,7 +5,7 @@ Installing go get github.com/gorilla/feeds -Feeds provides a simple, generic Feed interface with a generic Item object as well as RSS and Atom specific RssFeed and AtomFeed objects which allow access to all of each spec's defined elements. +Feeds provides a simple, generic Feed interface with a generic Item object as well as RSS, Atom and JSON Feed specific RssFeed, AtomFeed and JSONFeed objects which allow access to all of each spec's defined elements. Examples @@ -49,15 +49,17 @@ Create a Feed and some Items in that feed using the generic interfaces: }, } -From here, you can output Atom or RSS versions of this feed easily +From here, you can output Atom, RSS, or JSON Feed versions of this feed easily atom, err := feed.ToAtom() rss, err := feed.ToRss() + json, err := feed.ToJSON() You can also get access to the underlying objects that feeds uses to export its XML atomFeed := &Atom{feed}.AtomFeed() rssFeed := &Rss{feed}.RssFeed() + jsonFeed := &JSON{feed}.JSONFeed() From here, you can modify or add each syndication's specific fields before outputting @@ -65,6 +67,7 @@ From here, you can modify or add each syndication's specific fields before outpu atom, err := ToXML(atomFeed) rssFeed.Generator = "gorilla/feeds v1.0 (github.com/gorilla/feeds)" rss, err := ToXML(rssFeed) - + jsonFeed.NextUrl = "https://www.example.com/feed.json?page=2" + json, err := jsonFeed.ToJSON() */ package feeds diff --git a/feed.go b/feed.go index 6efce1d..70e0236 100644 --- a/feed.go +++ b/feed.go @@ -1,6 +1,7 @@ package feeds import ( + "encoding/json" "encoding/xml" "io" "time" @@ -105,3 +106,19 @@ func (f *Feed) ToRss() (string, error) { func (f *Feed) WriteRss(w io.Writer) error { return WriteXML(&Rss{f}, w) } + +// creates an JSON representation of this feed +func (f *Feed) ToJSON() (string, error) { + j := &JSON{f} + return j.ToJSON() +} + +// Writes an JSON representation of this feed to the writer. +func (f *Feed) WriteJSON(w io.Writer) error { + j := &JSON{f} + feed := j.JSONFeed() + + e := json.NewEncoder(w) + e.SetIndent("", " ") + return e.Encode(feed) +} diff --git a/feed_test.go b/feed_test.go index c3e31e7..e7ed38c 100644 --- a/feed_test.go +++ b/feed_test.go @@ -101,6 +101,56 @@ var rssOutput = ` ` +var jsonOutput = `{ + "version": "https://jsonfeed.org/version/1", + "title": "jmoiron.net blog", + "home_page_url": "http://jmoiron.net/blog", + "description": "discussion about tech, footie, photos", + "author": { + "name": "Jason Moiron" + }, + "items": [ + { + "id": "", + "url": "http://jmoiron.net/blog/limiting-concurrency-in-go/", + "title": "Limiting Concurrency in Go", + "summary": "A discussion on controlled parallelism in golang", + "date_published": "2013-01-16T21:52:35-05:00", + "author": { + "name": "Jason Moiron" + } + }, + { + "id": "", + "url": "http://jmoiron.net/blog/logicless-template-redux/", + "title": "Logic-less Template Redux", + "summary": "More thoughts on logicless templates", + "date_published": "2013-01-16T21:52:35-05:00" + }, + { + "id": "", + "url": "http://jmoiron.net/blog/idiomatic-code-reuse-in-go/", + "title": "Idiomatic Code Reuse in Go", + "summary": "How to use interfaces \u003cem\u003eeffectively\u003c/em\u003e", + "date_published": "2013-01-16T21:52:35-05:00" + }, + { + "id": "", + "url": "http://example.com/RickRoll.mp3", + "title": "Never Gonna Give You Up Mp3", + "summary": "Never gonna give you up - Never gonna let you down.", + "date_published": "2013-01-16T21:52:35-05:00" + }, + { + "id": "", + "url": "http://example.com/strings", + "title": "String formatting in Go", + "summary": "How to use things like %s, %v, %d, etc.", + "date_published": "2013-01-16T21:52:35-05:00" + } + ] +}` + func TestFeed(t *testing.T) { now, err := time.Parse(time.RFC3339, "2013-01-16T21:52:35-05:00") if err != nil { @@ -180,4 +230,19 @@ func TestFeed(t *testing.T) { if got := buf.String(); got != rssOutput { t.Errorf("Rss not what was expected. Got:\n%s\n\nExpected:\n%s\n", got, rssOutput) } + + json, err := feed.ToJSON() + if err != nil { + t.Errorf("unexpected error encoding JSON: %v", err) + } + if json != jsonOutput { + t.Errorf("JSON not what was expected. Got:\n%s\n\nExpected:\n%s\n", json, jsonOutput) + } + buf.Reset() + if err := feed.WriteJSON(&buf); err != nil { + t.Errorf("unexpected error writing JSON: %v", err) + } + if got := buf.String(); got != jsonOutput+"\n" { //json.Encode appends a newline after the JSON output: https://github.com/golang/go/commit/6f25f1d4c901417af1da65e41992d71c30f64f8f#diff-50848cbd686f250623a2ef6ddb07e157 + t.Errorf("JSON not what was expected. Got:\n||%s||\n\nExpected:\n||%s||\n", got, jsonOutput) + } } diff --git a/json.go b/json.go new file mode 100644 index 0000000..3cd71d1 --- /dev/null +++ b/json.go @@ -0,0 +1,174 @@ +package feeds + +import ( + "encoding/json" + "time" +) + +const jsonFeedVersion = "https://jsonfeed.org/version/1" + +// JSONAuthor represents the author of the feed or of an individual item +// in the feed +type JSONAuthor struct { + Name string `json:"name,omitempty"` + Url string `json:"url,omitempty"` + Avatar string `json:"avatar,omitempty"` +} + +// JSONAttachment represents a related resource. Podcasts, for instance, would +// include an attachment that’s an audio or video file. +type JSONAttachment struct { + Url string `json:"url,omitempty"` + MIMEType string `json:"mime_type,omitempty"` + Title string `json:"title,omitempty"` + Size int32 `json:"size,omitempty"` + Duration time.Duration `json:"duration_in_seconds,omitempty"` +} + +// MarshalJSON implements the json.Marshaler interface. +// The Duration field is marshaled in seconds, all other fields are marshaled +// based upon the definitions in struct tags. +func (a *JSONAttachment) MarshalJSON() ([]byte, error) { + type EmbeddedJSONAttachment JSONAttachment + return json.Marshal(&struct { + Duration float64 `json:"duration_in_seconds,omitempty"` + *EmbeddedJSONAttachment + }{ + EmbeddedJSONAttachment: (*EmbeddedJSONAttachment)(a), + Duration: a.Duration.Seconds(), + }) +} + +// UnmarshalJSON implements the json.Unmarshaler interface. +// The Duration field is expected to be in seconds, all other field types +// match the struct definition. +func (a *JSONAttachment) UnmarshalJSON(data []byte) error { + type EmbeddedJSONAttachment JSONAttachment + var raw struct { + Duration float64 `json:"duration_in_seconds,omitempty"` + *EmbeddedJSONAttachment + } + raw.EmbeddedJSONAttachment = (*EmbeddedJSONAttachment)(a) + + err := json.Unmarshal(data, &raw) + if err != nil { + return err + } + + if raw.Duration > 0 { + nsec := int64(raw.Duration * float64(time.Second)) + raw.EmbeddedJSONAttachment.Duration = time.Duration(nsec) + } + + return nil +} + +// JSONItem represents a single entry/post for the feed. +type JSONItem struct { + Id string `json:"id"` + Url string `json:"url,omitempty"` + ExternalUrl string `json:"external_url,omitempty"` + Title string `json:"title,omitempty"` + ContentHTML string `json:"content_html,omitempty"` + ContentText string `json:"content_text,omitempty"` + Summary string `json:"summary,omitempty"` + Image string `json:"image,omitempty"` + BannerImage string `json:"banner_,omitempty"` + PublishedDate *time.Time `json:"date_published,omitempty"` + ModifiedDate *time.Time `json:"date_modified,omitempty"` + Author *JSONAuthor `json:"author,omitempty"` + Tags []string `json:"tags,omitempty"` + Attachments []JSONAttachment `json:"attachments,omitempty"` +} + +// JSONHub describes an endpoint that can be used to subscribe to real-time +// notifications from the publisher of this feed. +type JSONHub struct { + Type string `json:"type"` + Url string `json:"url"` +} + +// JSONFeed represents a syndication feed in the JSON Feed Version 1 format. +// Matching the specification found here: https://jsonfeed.org/version/1. +type JSONFeed struct { + Version string `json:"version"` + Title string `json:"title"` + HomePageUrl string `json:"home_page_url,omitempty"` + FeedUrl string `json:"feed_url,omitempty"` + Description string `json:"description,omitempty"` + UserComment string `json:"user_comment,omitempty"` + NextUrl string `json:"next_url,omitempty"` + Icon string `json:"icon,omitempty"` + Favicon string `json:"favicon,omitempty"` + Author *JSONAuthor `json:"author,omitempty"` + Expired *bool `json:"expired,omitempty"` + Hubs []*JSONItem `json:"hubs,omitempty"` + Items []*JSONItem `json:"items,omitempty"` +} + +type JSON struct { + *Feed +} + +func (f *JSON) ToJSON() (string, error) { + return f.JSONFeed().ToJSON() +} + +func (f *JSONFeed) ToJSON() (string, error) { + data, err := json.MarshalIndent(f, "", " ") + if err != nil { + return "", err + } + + return string(data), nil +} + +// JSONFeed creates a new JSONFeed with a generic Feed struct's data +func (a *JSON) JSONFeed() *JSONFeed { + feed := &JSONFeed{ + Version: jsonFeedVersion, + Title: a.Title, + Description: a.Description, + } + + if a.Link != nil { + feed.HomePageUrl = a.Link.Href + } + if a.Author != nil { + feed.Author = &JSONAuthor{ + Name: a.Author.Name, + } + } + for _, e := range a.Items { + feed.Items = append(feed.Items, newJSONItem(e)) + } + return feed +} + +func newJSONItem(i *Item) *JSONItem { + item := &JSONItem{ + Id: i.Id, + Title: i.Title, + Summary: i.Description, + } + + if i.Link != nil { + item.Url = i.Link.Href + } + if i.Source != nil { + item.ExternalUrl = i.Source.Href + } + if i.Author != nil { + item.Author = &JSONAuthor{ + Name: i.Author.Name, + } + } + if !i.Created.IsZero() { + item.PublishedDate = &i.Created + } + if !i.Updated.IsZero() { + item.ModifiedDate = &i.Created + } + + return item +}