diff --git a/api/api.go b/api/api.go index d3c48aa..8b6bf49 100644 --- a/api/api.go +++ b/api/api.go @@ -1,16 +1,11 @@ package api import ( - "encoding/json" - "fmt" - "net/http" - "strconv" "time" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/marhycz/strv-go-newsletter/environment" - "github.com/marhycz/strv-go-newsletter/repository/store" ) type Rest struct { @@ -26,7 +21,7 @@ func NewController(env *environment.Env) *Rest { return c } -func (rest *Rest) initRouter(){ +func (rest *Rest) initRouter() { r := chi.NewRouter() // A good base middleware stack @@ -40,60 +35,8 @@ func (rest *Rest) initRouter(){ // processing should be stopped. r.Use(middleware.Timeout(60 * time.Second)) - r.Route("/subscriptions", func(r chi.Router){ - r.Get("/", func(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - subscriptions := rest.env.Store.GetSubscriptions(ctx) - err := json.NewEncoder(w).Encode(subscriptions) - if err != nil { - w.WriteHeader(http.StatusNotFound) - } - }) - - r.Get("/{newsletter_id}/{email}", func(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - id, error := strconv.Atoi(chi.URLParam(r, "newsletter_id")) - - if error != nil { - fmt.Println("Error during conversion") - return - } - subscriptions := rest.env.Store.GetSubscription(ctx, id, chi.URLParam(r, "email")) - err := json.NewEncoder(w).Encode(subscriptions) - if err != nil { - w.WriteHeader(http.StatusNotFound) - } - }) - - r.Post("/", func(w http.ResponseWriter, r *http.Request) { - var sub store.Subscription - - json.NewDecoder(r.Body).Decode(&sub) - ctx := r.Context() - - subscriptions := rest.env.Store.NewSubscription(ctx, sub.Newsletter_id, sub.Email) - - err := json.NewEncoder(w).Encode(subscriptions) - if err != nil { - w.WriteHeader(http.StatusNotFound) - } - }) - - r.Delete("/", func(w http.ResponseWriter, r *http.Request) { - var sub store.Subscription - - json.NewDecoder(r.Body).Decode(&sub) - ctx := r.Context() - - subscriptions := rest.env.Store.DeleteSubscription(ctx, sub.Id) - - err := json.NewEncoder(w).Encode(subscriptions) - if err != nil { - w.WriteHeader(http.StatusNotFound) - } - }) - }) - rest.routeEditor(r) + rest.routeSubscriptions(r) + rest.routeIssues(r) rest.Mux = r } diff --git a/api/issue.go b/api/issue.go new file mode 100644 index 0000000..79dc645 --- /dev/null +++ b/api/issue.go @@ -0,0 +1,80 @@ +package api + +import ( + "encoding/json" + "io" + "log" + "net/http" + "os" + + "github.com/go-chi/chi/v5" +) + +func (rest *Rest) routeIssues(r *chi.Mux) { + r.Route("/issues", func(r chi.Router) { + r.Get("/", rest.listOfIssues) + + r.Get("/issue", rest.getIssue) + + r.Post("/issue", rest.publishIssue) + }) +} + +func (rest *Rest) listOfIssues(w http.ResponseWriter, r *http.Request) { + newsletter := r.URL.Query().Get("newsletter_id") + + if newsletter != "" { + newsletter = newsletter + "/" + } + + ctx := r.Context() + subscriptions := rest.env.Storage.GetIssuesList(ctx, os.Stdout, "/", newsletter) + err := json.NewEncoder(w).Encode(subscriptions) + if err != nil { + w.WriteHeader(http.StatusNotFound) + } +} +func (rest *Rest) getIssue(w http.ResponseWriter, r *http.Request) { + newsletter := r.URL.Query().Get("newsletter_id") + name := r.URL.Query().Get("name") + path := newsletter + "/" + name + + if newsletter == "" || name == "" { + w.WriteHeader(http.StatusForbidden) + } + + ctx := r.Context() + subscriptions, downlFailure := rest.env.Storage.DownloadFileIntoMemory(ctx, os.Stdout, path) + + if downlFailure != nil { + w.WriteHeader(http.StatusNoContent) + } + + err := json.NewEncoder(w).Encode(subscriptions) + if err != nil { + w.WriteHeader(http.StatusNotFound) + } +} + +func (rest *Rest) publishIssue(w http.ResponseWriter, r *http.Request) { + newsletter := r.URL.Query().Get("newsletter_id") + name := r.URL.Query().Get("name") + path := newsletter + "/" + name + body, bodyErr := io.ReadAll(r.Body) + if bodyErr != nil { + log.Fatalln(bodyErr) + } + data := string(body) + + if newsletter == "" || name == "" || data == "" { + w.WriteHeader(http.StatusForbidden) + } + + ctx := r.Context() + subscriptions := rest.env.Storage.StreamFileUpload(ctx, os.Stdout, path, data) + + err := json.NewEncoder(w).Encode(subscriptions) + if err != nil { + w.WriteHeader(http.StatusNotFound) + } +} diff --git a/api/subscriptions.go b/api/subscriptions.go new file mode 100644 index 0000000..e1c1a51 --- /dev/null +++ b/api/subscriptions.go @@ -0,0 +1,77 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + "github.com/marhycz/strv-go-newsletter/repository/store" +) + +func (rest *Rest) routeSubscriptions(r *chi.Mux) { + r.Route("/subscriptions", func(r chi.Router) { + r.Get("/", rest.listOfSubscriptions) + + r.Get("/{newsletter_id}/{email}", rest.getSubscription) + + r.Post("/", rest.subscribe) + + r.Delete("/", rest.unsubscribe) + }) +} + +func (rest *Rest) listOfSubscriptions(w http.ResponseWriter, r *http.Request) { + + ctx := r.Context() + subscriptions := rest.env.Store.GetSubscriptions(ctx) + err := json.NewEncoder(w).Encode(subscriptions) + if err != nil { + w.WriteHeader(http.StatusNotFound) + } +} + +func (rest *Rest) getSubscription(w http.ResponseWriter, r *http.Request) { + + ctx := r.Context() + id, error := strconv.Atoi(chi.URLParam(r, "newsletter_id")) + + if error != nil { + fmt.Println("Error during conversion") + return + } + subscriptions := rest.env.Store.GetSubscription(ctx, id, chi.URLParam(r, "email")) + err := json.NewEncoder(w).Encode(subscriptions) + if err != nil { + w.WriteHeader(http.StatusNotFound) + } +} +func (rest *Rest) subscribe(w http.ResponseWriter, r *http.Request) { + + var sub store.Subscription + + json.NewDecoder(r.Body).Decode(&sub) + ctx := r.Context() + + subscriptions := rest.env.Store.NewSubscription(ctx, sub.Newsletter_id, sub.Email) + + err := json.NewEncoder(w).Encode(subscriptions) + if err != nil { + w.WriteHeader(http.StatusNotFound) + } +} +func (rest *Rest) unsubscribe(w http.ResponseWriter, r *http.Request) { + + var sub store.Subscription + + json.NewDecoder(r.Body).Decode(&sub) + ctx := r.Context() + + subscriptions := rest.env.Store.DeleteSubscription(ctx, sub.Id) + + err := json.NewEncoder(w).Encode(subscriptions) + if err != nil { + w.WriteHeader(http.StatusNotFound) + } +} diff --git a/environment/environment.go b/environment/environment.go index a4872e7..cbf9757 100644 --- a/environment/environment.go +++ b/environment/environment.go @@ -2,10 +2,12 @@ package environment import ( "github.com/marhycz/strv-go-newsletter/repository/database" + "github.com/marhycz/strv-go-newsletter/repository/storage" "github.com/marhycz/strv-go-newsletter/repository/store" ) type Env struct { Database *database.Database Store *store.Store + Storage *storage.Storage } diff --git a/go.mod b/go.mod index f61b75e..8ac4944 100644 --- a/go.mod +++ b/go.mod @@ -5,9 +5,11 @@ go 1.20 require github.com/jackc/pgx/v5 v5.3.1 require ( + github.com/MicahParks/keyfunc v1.9.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/leodido/go-urn v1.2.3 // indirect + google.golang.org/appengine/v2 v2.0.2 // indirect ) require ( @@ -19,11 +21,12 @@ require ( cloud.google.com/go/longrunning v0.4.1 // indirect cloud.google.com/go/storage v1.30.1 // indirect firebase.google.com/go v3.13.0+incompatible + firebase.google.com/go/v4 v4.11.0 github.com/georgysavva/scany/v2 v2.0.0 github.com/go-chi/chi/v5 v5.0.8 github.com/go-playground/validator/v10 v10.13.0 github.com/golang-jwt/jwt/v4 v4.5.0 - github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e // indirect + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.3 // indirect github.com/gomarkdown/markdown v0.0.0-20230322041520-c84983bdbf2a github.com/google/go-cmp v0.5.9 // indirect @@ -43,7 +46,7 @@ require ( golang.org/x/sync v0.1.0 // indirect golang.org/x/sys v0.8.0 // indirect golang.org/x/text v0.9.0 // indirect - golang.org/x/time v0.1.0 // indirect + golang.org/x/time v0.3.0 // indirect golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect google.golang.org/api v0.123.0 google.golang.org/appengine v1.6.7 // indirect diff --git a/go.sum b/go.sum index 8a66b7d..cc7417e 100644 --- a/go.sum +++ b/go.sum @@ -16,7 +16,11 @@ cloud.google.com/go/storage v1.30.1 h1:uOdMxAs8HExqBlnLtnQyP0YkvbiDpdGShGKtx6U/o cloud.google.com/go/storage v1.30.1/go.mod h1:NfxhC0UJE1aXSx7CIIbCf7y9HKT7BiccwkR7+P7gN8E= firebase.google.com/go v3.13.0+incompatible h1:3TdYC3DDi6aHn20qoRkxwGqNgdjtblwVAyRLQwGn/+4= firebase.google.com/go v3.13.0+incompatible/go.mod h1:xlah6XbEyW6tbfSklcfe5FHJIwjt8toICdV5Wh9ptHs= +firebase.google.com/go/v4 v4.11.0 h1:szjBoiF33A2FavRLIDZjW1mw+OsW/XAtHoYNIqWOjRk= +firebase.google.com/go/v4 v4.11.0/go.mod h1:60c36dWLK4+j05Vw5XMllek3b3PCynU3BfI46OSwsUE= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o= +github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= @@ -50,11 +54,14 @@ github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91 github.com/go-playground/validator/v10 v10.13.0 h1:cFRQdfaSMCOSfGCCLB20MHvuoHb/s5G8L5pu2ppK5AQ= github.com/go-playground/validator/v10 v10.13.0/go.mod h1:dwu7+CG8/CtBiJFZDz4e+5Upb6OLw04gtBYw0mcG/z4= github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw= +github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e h1:1r7pUrabqp18hOBcwBwiTsbnFeTZHV9eER/QT5JVZxY= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -153,6 +160,7 @@ golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81R golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220708220712-1185a9018129/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= @@ -191,6 +199,8 @@ golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/time v0.1.0 h1:xYY+Bajn2a7VBmTM5GikTmnK8ZuX8YgnQCqZpbBNtmA= golang.org/x/time v0.1.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= +golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= @@ -209,6 +219,8 @@ google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9Ywl google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine/v2 v2.0.2 h1:MSqyWy2shDLwG7chbwBJ5uMyw6SNqJzhJHNDwYB0Akk= +google.golang.org/appengine/v2 v2.0.2/go.mod h1:PkgRUWz4o1XOvbqtWTkBtCitEJ5Tp4HoVEdMMYQR/8E= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= diff --git a/main.go b/main.go index 960bfd5..51df459 100644 --- a/main.go +++ b/main.go @@ -10,6 +10,7 @@ import ( "github.com/marhycz/strv-go-newsletter/api" "github.com/marhycz/strv-go-newsletter/environment" "github.com/marhycz/strv-go-newsletter/repository/database" + "github.com/marhycz/strv-go-newsletter/repository/storage" "github.com/marhycz/strv-go-newsletter/repository/store" ) @@ -21,6 +22,7 @@ func main() { env := &environment.Env{ Database: database.NewConnection(ctx), Store: store.NewConnection(ctx), + Storage: storage.NewConnection(ctx), } controller := api.NewController(env) diff --git a/repository/storage/model.go b/repository/storage/model.go new file mode 100644 index 0000000..bf5803a --- /dev/null +++ b/repository/storage/model.go @@ -0,0 +1,7 @@ +package storage + +type Issue struct { + Name string `json:"name"` + Id string `json:"id"` + Newsletter_id int `json:"newsletter_id"` +} diff --git a/repository/storage/storage.go b/repository/storage/storage.go new file mode 100644 index 0000000..68e9abd --- /dev/null +++ b/repository/storage/storage.go @@ -0,0 +1,117 @@ +package storage + +import ( + "context" + "fmt" + "io" + "log" + "time" + + "encoding/base64" + + storageFn "cloud.google.com/go/storage" + firebase "firebase.google.com/go/v4" + storage "firebase.google.com/go/v4/storage" + "google.golang.org/api/iterator" + "google.golang.org/api/option" +) + +type Storage struct { + client *storage.Client +} + +func NewConnection(ctx context.Context) *Storage { + config := &firebase.Config{ + StorageBucket: "strv-go-newsletter.appspot.com", + } + opt := option.WithCredentialsFile("config/firebase_key.json") + app, err := firebase.NewApp(context.Background(), config, opt) + if err != nil { + log.Fatalln(err) + } + + client, err := app.Storage(context.Background()) + if err != nil { + log.Fatalln(err) + } + + fb := &Storage{ + client: client, + } + + return fb + // 'bucket' is an object defined in the cloud.google.com/go/storage package. + // See https://godoc.org/cloudgoogle.com/go/storage#BucketHandle + // for more details.storage.go +} + +func (fb *Storage) GetIssuesList(ctx context.Context, w io.Writer, delim string, prefix string) error { + bucket, err := fb.client.DefaultBucket() + if err != nil { + log.Fatalln(err) + } + + it := bucket.Objects(ctx, &storageFn.Query{ + Prefix: prefix, + Delimiter: delim, + }) + for { + attrs, err := it.Next() + if err == iterator.Done { + break + } + if err != nil { + return fmt.Errorf("Bucket(%v).Objects: %w", bucket, err) + } + fmt.Fprintln(w, attrs.Name) + } + return nil +} + +func (fb *Storage) DownloadFileIntoMemory(ctx context.Context, w io.Writer, issue string) ([]byte, error) { + bucket, err := fb.client.DefaultBucket() + if err != nil { + log.Fatalln(err) + } + + rc, err := bucket.Object(issue).NewReader(ctx) + if err != nil { + return nil, fmt.Errorf("Object(%q).NewReader: %w", issue, err) + } + defer rc.Close() + + data, err := io.ReadAll(rc) + if err != nil { + return nil, fmt.Errorf("ioutil.ReadAll: %w", err) + } + fmt.Fprintf(w, "Blob %v downloaded.\n", issue) + return data, nil +} + +func (fb *Storage) StreamFileUpload(ctx context.Context, w io.Writer, issue string, data string) error { + bucket, err := fb.client.DefaultBucket() + if err != nil { + log.Fatalln(err) + } + + md, _ := base64.StdEncoding.DecodeString(data) + + ctx, cancel := context.WithTimeout(ctx, time.Second*50) + defer cancel() + + // Upload an object with storage.Writer. + wc := bucket.Object(issue).NewWriter(ctx) + wc.ContentType = "text/markdown" + wc.ChunkSize = 0 // note retries are not supported for chunk size 0. + + if _, err := wc.Write(md); err != nil { + return fmt.Errorf("Writer.Write: %w", err) + } + // Data can continue to be added to the file until the writer is closed. + if err := wc.Close(); err != nil { + return fmt.Errorf("Writer.Close: %w", err) + } + fmt.Fprintf(w, "%v uploaded.\n", issue) + + return nil +}