From 64fdbf2c0719ef9ad46e819f5e6be1ce8e4f7e53 Mon Sep 17 00:00:00 2001 From: Benjamin Bengfort Date: Sat, 17 Feb 2024 03:05:43 -0600 Subject: [PATCH] API Links List (#26) --- cmd/rtnl/main.go | 81 ++++++++++++++- pkg/api/v1/api.go | 7 +- pkg/rtnl/rtnl.go | 2 +- pkg/rtnl/shorten.go | 20 +++- pkg/rtnl/static/styles/output.css | 159 ++++++------------------------ pkg/storage/links.go | 34 +++++++ pkg/storage/links_test.go | 1 + pkg/storage/models/links.go | 2 +- pkg/storage/storage.go | 1 + 9 files changed, 170 insertions(+), 137 deletions(-) create mode 100644 pkg/storage/links_test.go diff --git a/cmd/rtnl/main.go b/cmd/rtnl/main.go index a100536..c280aa5 100644 --- a/cmd/rtnl/main.go +++ b/cmd/rtnl/main.go @@ -25,8 +25,9 @@ import ( ) var ( - conf config.Config - svc api.Service + conf config.Config + svc api.Service + store *badger.DB timeout = 30 * time.Second ) @@ -101,6 +102,14 @@ func main() { }, }, }, + { + Name: "list", + Category: "client", + Usage: "get list of short urls stored on the server", + Action: listLinks, + Before: makeClient, + Flags: []cli.Flag{}, + }, { Name: "info", Category: "client", @@ -136,6 +145,15 @@ func main() { Before: makeClient, Flags: []cli.Flag{}, }, + { + Name: "db:keys", + Category: "debug", + Usage: "print out all of the keys in the local database", + Action: dbKeys, + Before: openStore, + After: closeStore, + Flags: []cli.Flag{}, + }, } if err := app.Run(os.Args); err != nil { @@ -312,6 +330,18 @@ func shorten(c *cli.Context) (err error) { return display(out) } +func listLinks(c *cli.Context) (err error) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + var out *api.ShortURLList + if out, err = svc.ShortURLList(ctx, nil); err != nil { + return cli.Exit(err, 1) + } + + return display(out) +} + func info(c *cli.Context) (err error) { if c.NArg() == 0 { return cli.Exit("specify at least one short url ID to get info for", 1) @@ -413,6 +443,30 @@ func status(c *cli.Context) (err error) { return display(status) } +//=========================================================================== +// Debug Commands +//=========================================================================== + +func dbKeys(c *cli.Context) error { + err := store.View(func(txn *badger.Txn) error { + opts := badger.DefaultIteratorOptions + opts.PrefetchValues = false + it := txn.NewIterator(opts) + defer it.Close() + for it.Rewind(); it.Valid(); it.Next() { + item := it.Item() + k := item.Key() + fmt.Printf("key=%s\n", k) + } + return nil + }) + + if err != nil { + return cli.Exit(err, 1) + } + return nil +} + //=========================================================================== // Helper Commands //=========================================================================== @@ -431,6 +485,29 @@ func makeClient(c *cli.Context) (err error) { return nil } +func openStore(c *cli.Context) (err error) { + if err = configure(c); err != nil { + return err + } + + opts := badger.DefaultOptions(conf.Storage.DataPath) + opts.ReadOnly = conf.Storage.ReadOnly + opts.Logger = nil + + if store, err = badger.Open(opts); err != nil { + return cli.Exit(err, 1) + } + + return nil +} + +func closeStore(c *cli.Context) (err error) { + if err = store.Close(); err != nil { + return cli.Exit(err, 1) + } + return nil +} + func display(v any) error { encoder := json.NewEncoder(os.Stdout) encoder.SetIndent("", " ") diff --git a/pkg/api/v1/api.go b/pkg/api/v1/api.go index f5d2516..74052ea 100644 --- a/pkg/api/v1/api.go +++ b/pkg/api/v1/api.go @@ -15,10 +15,15 @@ type Service interface { Status(context.Context) (*StatusReply, error) // URL Management + // TODO: edit short url with details ShortURLList(context.Context, *PageQuery) (*ShortURLList, error) ShortenURL(context.Context, *LongURL) (*ShortURL, error) ShortURLInfo(context.Context, string) (*ShortURL, error) DeleteShortURL(context.Context, string) error + + // Campaigns + + // Websockets Updates(context.Context, string) (<-chan *Click, error) } @@ -65,7 +70,7 @@ type ShortURL struct { URL string `json:"url"` AltURL string `json:"alt_url,omitempty"` Title string `json:"title"` - Description string `json:"description"` + Description string `json:"description,omitempty"` Visits uint64 `json:"visits"` Expires *time.Time `json:"expires,omitempty"` Created *time.Time `json:"created,omitempty"` diff --git a/pkg/rtnl/rtnl.go b/pkg/rtnl/rtnl.go index 9989168..4fd3f34 100644 --- a/pkg/rtnl/rtnl.go +++ b/pkg/rtnl/rtnl.go @@ -266,7 +266,7 @@ func (s *Server) Routes(router *gin.Engine) (err error) { // Permenant Routes router.GET("/:id", s.Redirect) - router.GET("/:id/info", s.ShortURLDetail) + router.GET("/:id/info", s.Authenticate, s.ShortURLDetail) router.DELETE("/:id", s.Authenticate, s.DeleteShortURL) // Web Links diff --git a/pkg/rtnl/shorten.go b/pkg/rtnl/shorten.go index f3c36a3..2b90546 100644 --- a/pkg/rtnl/shorten.go +++ b/pkg/rtnl/shorten.go @@ -173,9 +173,27 @@ func (s *Server) ShortURLList(c *gin.Context) { } // Retrieve the page from the database + // TODO: pass the page query to the listing function + var urls []*models.ShortURL + if urls, err = s.db.List(); err != nil { + log.Warn().Err(err).Msg("could not retrieve short url list from db") + c.JSON(http.StatusInternalServerError, api.ErrorResponse("could not complete request")) + return + } // Create the API response to send back to the user. - out = &api.ShortURLList{} + out = &api.ShortURLList{ + URLs: make([]*api.ShortURL, 0, len(urls)), + Page: &api.PageQuery{}, + } + + for _, url := range urls { + out.URLs = append(out.URLs, &api.ShortURL{ + URL: base62.Encode(url.ID), + Title: url.Title, + Visits: url.Visits, + }) + } c.Negotiate(http.StatusOK, gin.Negotiate{ Offered: []string{gin.MIMEHTML, gin.MIMEJSON}, diff --git a/pkg/rtnl/static/styles/output.css b/pkg/rtnl/static/styles/output.css index 201eb8d..bf67d8e 100644 --- a/pkg/rtnl/static/styles/output.css +++ b/pkg/rtnl/static/styles/output.css @@ -553,11 +553,6 @@ video { margin-right: auto; } -.my-6 { - margin-top: 1.5rem; - margin-bottom: 1.5rem; -} - .my-auto { margin-top: auto; margin-bottom: auto; @@ -591,14 +586,14 @@ video { margin-top: 4rem; } -.mt-4 { - margin-top: 1rem; -} - .mt-3 { margin-top: 0.75rem; } +.mt-4 { + margin-top: 1rem; +} + .block { display: block; } @@ -627,10 +622,6 @@ video { height: 2.5rem; } -.h-\[200px\] { - height: 200px; -} - .w-10 { width: 2.5rem; } @@ -647,26 +638,14 @@ video { width: 275px; } -.w-\[575px\] { - width: 575px; -} - -.w-screen { - width: 100vw; +.w-\[32px\] { + width: 32px; } .w-\[400px\] { width: 400px; } -.w-full { - width: 100%; -} - -.w-\[32px\] { - width: 32px; -} - .flex-row { flex-direction: row; } @@ -708,8 +687,9 @@ video { border-color: rgb(209 213 219 / var(--tw-border-opacity)); } -.bg-\[\#238553FF\] { - background-color: #238553FF; +.bg-dartmouth { + --tw-bg-opacity: 1; + background-color: rgb(0 116 63 / var(--tw-bg-opacity)); } .bg-gray-50 { @@ -722,21 +702,6 @@ video { background-color: rgb(29 101 166 / var(--tw-bg-opacity)); } -.bg-mint { - --tw-bg-opacity: 1; - background-color: rgb(70 180 127 / var(--tw-bg-opacity)); -} - -.bg-red-700 { - --tw-bg-opacity: 1; - background-color: rgb(185 28 28 / var(--tw-bg-opacity)); -} - -.bg-dartmouth { - --tw-bg-opacity: 1; - background-color: rgb(0 116 63 / var(--tw-bg-opacity)); -} - .bg-orioles { --tw-bg-opacity: 1; background-color: rgb(236 80 18 / var(--tw-bg-opacity)); @@ -755,27 +720,18 @@ video { padding-bottom: 3.5rem; } -.px-4 { - padding-left: 1rem; - padding-right: 1rem; -} - .pl-2 { padding-left: 0.5rem; } -.pt-\[400px\] { - padding-top: 400px; +.pt-1 { + padding-top: 0.25rem; } .pt-2 { padding-top: 0.5rem; } -.pt-1 { - padding-top: 0.25rem; -} - .text-center { text-align: center; } @@ -789,16 +745,16 @@ video { line-height: 2rem; } -.text-xl { - font-size: 1.25rem; - line-height: 1.75rem; -} - .text-sm { font-size: 0.875rem; line-height: 1.25rem; } +.text-xl { + font-size: 1.25rem; + line-height: 1.75rem; +} + .font-bold { font-weight: 700; } @@ -807,44 +763,29 @@ video { font-weight: 600; } -.text-\[\#192E5B\] { - --tw-text-opacity: 1; - color: rgb(25 46 91 / var(--tw-text-opacity)); -} - .text-blue-600 { --tw-text-opacity: 1; color: rgb(37 99 235 / var(--tw-text-opacity)); } -.text-space-cadet { - --tw-text-opacity: 1; - color: rgb(25 46 91 / var(--tw-text-opacity)); -} - -.text-white { - --tw-text-opacity: 1; - color: rgb(255 255 255 / var(--tw-text-opacity)); -} - -.text-slate-100 { +.text-red-400 { --tw-text-opacity: 1; - color: rgb(241 245 249 / var(--tw-text-opacity)); + color: rgb(248 113 113 / var(--tw-text-opacity)); } -.text-slate-500 { +.text-slate-400 { --tw-text-opacity: 1; - color: rgb(100 116 139 / var(--tw-text-opacity)); + color: rgb(148 163 184 / var(--tw-text-opacity)); } -.text-slate-400 { +.text-space-cadet { --tw-text-opacity: 1; - color: rgb(148 163 184 / var(--tw-text-opacity)); + color: rgb(25 46 91 / var(--tw-text-opacity)); } -.text-red-400 { +.text-white { --tw-text-opacity: 1; - color: rgb(248 113 113 / var(--tw-text-opacity)); + color: rgb(255 255 255 / var(--tw-text-opacity)); } .underline { @@ -880,24 +821,14 @@ footer { padding: 24px 16px; } -.hover\:bg-\[\#192E5B\]:hover { - --tw-bg-opacity: 1; - background-color: rgb(25 46 91 / var(--tw-bg-opacity)); -} - -.hover\:bg-\[\#1C6941\]:hover { - --tw-bg-opacity: 1; - background-color: rgb(28 105 65 / var(--tw-bg-opacity)); -} - -.hover\:bg-dartmouth:hover { +.hover\:bg-mint:hover { --tw-bg-opacity: 1; - background-color: rgb(0 116 63 / var(--tw-bg-opacity)); + background-color: rgb(70 180 127 / var(--tw-bg-opacity)); } -.hover\:bg-red-900:hover { +.hover\:bg-sinopia:hover { --tw-bg-opacity: 1; - background-color: rgb(127 29 29 / var(--tw-bg-opacity)); + background-color: rgb(219 59 0 / var(--tw-bg-opacity)); } .hover\:bg-space-cadet:hover { @@ -905,30 +836,10 @@ footer { background-color: rgb(25 46 91 / var(--tw-bg-opacity)); } -.hover\:bg-mint:hover { - --tw-bg-opacity: 1; - background-color: rgb(70 180 127 / var(--tw-bg-opacity)); -} - -.hover\:bg-sinopia:hover { - --tw-bg-opacity: 1; - background-color: rgb(219 59 0 / var(--tw-bg-opacity)); -} - .hover\:font-semibold:hover { font-weight: 600; } -.hover\:text-space-cadet:hover { - --tw-text-opacity: 1; - color: rgb(25 46 91 / var(--tw-text-opacity)); -} - -.hover\:text-ghost:hover { - --tw-text-opacity: 1; - color: rgb(248 248 255 / var(--tw-text-opacity)); -} - .hover\:text-air-superiority:hover { --tw-text-opacity: 1; color: rgb(114 162 192 / var(--tw-text-opacity)); @@ -948,21 +859,7 @@ footer { --tw-ring-color: rgb(59 130 246 / var(--tw-ring-opacity)); } -@media (min-width: 640px) { - .sm\:w-\[400px\] { - width: 400px; - } - - .sm\:w-3\/4 { - width: 75%; - } -} - @media (min-width: 768px) { - .md\:w-\[575px\] { - width: 575px; - } - .md\:w-\[675px\] { width: 675px; } diff --git a/pkg/storage/links.go b/pkg/storage/links.go index 7cc9741..d0ab2be 100644 --- a/pkg/storage/links.go +++ b/pkg/storage/links.go @@ -40,6 +40,40 @@ func (s *Store) Save(obj *models.ShortURL) error { return err } +// TODO: support pagination when listing links. +func (s *Store) List() ([]*models.ShortURL, error) { + urls := make([]*models.ShortURL, 0) + + err := s.db.View(func(txn *badger.Txn) error { + it := txn.NewIterator(badger.DefaultIteratorOptions) + defer it.Close() + + prefix := models.LinksBucket[:] + for it.Seek(prefix); it.ValidForPrefix(prefix); it.Next() { + item := it.Item() + obj := &models.ShortURL{} + + err := item.Value(func(v []byte) error { + return obj.UnmarshalValue(v) + }) + + if err != nil { + return err + } + + urls = append(urls, obj) + } + + return nil + }) + + if err != nil { + return nil, err + } + + return urls, nil +} + func (s *Store) Load(key uint64) (string, error) { obj := &models.ShortURL{ID: key} keyb := obj.Key() diff --git a/pkg/storage/links_test.go b/pkg/storage/links_test.go new file mode 100644 index 0000000..296b4ef --- /dev/null +++ b/pkg/storage/links_test.go @@ -0,0 +1 @@ +package storage_test diff --git a/pkg/storage/models/links.go b/pkg/storage/models/links.go index 3c0933d..4999619 100644 --- a/pkg/storage/models/links.go +++ b/pkg/storage/models/links.go @@ -14,7 +14,7 @@ import ( // // A Campaign is a relationship between shortened URLs that have different marketing // purposes. For example, we might shorten a webinar link then create campaign links -// for sendgrid, twiter, linkedin, etc. The purpose of the campaign is to identify what +// for sendgrid, twitter, linkedin, etc. The purpose of the campaign is to identify what // channels are performing best. In terms of the data structure, a short URL can either // have a campaign id -- meaning it is a campaign link for another URL or it can have // a list of campaigns, it's sublinks. Technically a tree-structure is possible, but in diff --git a/pkg/storage/storage.go b/pkg/storage/storage.go index a18812e..984ddba 100644 --- a/pkg/storage/storage.go +++ b/pkg/storage/storage.go @@ -17,6 +17,7 @@ type Storage interface { type LinkStorage interface { Save(*models.ShortURL) error + List() ([]*models.ShortURL, error) Load(uint64) (string, error) LoadInfo(uint64) (*models.ShortURL, error) Delete(uint64) error