Initial commit

This commit is contained in:
2020-12-21 15:56:56 +01:00
commit d9a6c14041
50 changed files with 10235 additions and 0 deletions

View File

@ -0,0 +1,41 @@
package command
import (
"context"
"github.com/pkg/errors"
"forge.cadoles.com/Cadoles/fake-sms/internal/model"
"forge.cadoles.com/Cadoles/fake-sms/internal/storm"
"gitlab.com/wpetit/goweb/cqrs"
"gitlab.com/wpetit/goweb/middleware/container"
)
type ClearOutboxRequest struct{}
func HandleClearOutbox(ctx context.Context, cmd cqrs.Command) error {
_, ok := cmd.Request().(*ClearOutboxRequest)
if !ok {
return cqrs.ErrUnexpectedRequest
}
ctn, err := container.From(ctx)
if err != nil {
return errors.Wrap(err, "could not retrieve service container")
}
db, err := storm.From(ctn)
if err != nil {
return errors.Wrap(err, "could not retrieve storm service")
}
if err := db.Select().Delete(&model.SMS{}); err != nil {
if err == storm.ErrNotFound {
return nil
}
return errors.Wrap(err, "could not delete messages")
}
return nil
}

View File

@ -0,0 +1,43 @@
package command
import (
"context"
"github.com/pkg/errors"
"forge.cadoles.com/Cadoles/fake-sms/internal/model"
"forge.cadoles.com/Cadoles/fake-sms/internal/storm"
"gitlab.com/wpetit/goweb/cqrs"
"gitlab.com/wpetit/goweb/middleware/container"
)
type DeleteSMSRequest struct {
SMSID int
}
func HandleDeleteSMS(ctx context.Context, cmd cqrs.Command) error {
req, ok := cmd.Request().(*DeleteSMSRequest)
if !ok {
return cqrs.ErrUnexpectedRequest
}
ctn, err := container.From(ctx)
if err != nil {
return errors.Wrap(err, "could not retrieve service container")
}
db, err := storm.From(ctn)
if err != nil {
return errors.Wrap(err, "could not retrieve storm service")
}
if err := db.DeleteStruct(&model.SMS{ID: req.SMSID}); err != nil {
if err == storm.ErrNotFound {
return nil
}
return errors.Wrap(err, "could not delete email")
}
return nil
}

View File

@ -0,0 +1,48 @@
package command
import (
"context"
"time"
"github.com/pkg/errors"
"forge.cadoles.com/Cadoles/fake-sms/internal/model"
"forge.cadoles.com/Cadoles/fake-sms/internal/storm"
"gitlab.com/wpetit/goweb/cqrs"
"gitlab.com/wpetit/goweb/middleware/container"
)
type StoreSMSRequest struct {
Body string
Recipient string
}
func HandleStoreSMS(ctx context.Context, cmd cqrs.Command) error {
req, ok := cmd.Request().(*StoreSMSRequest)
if !ok {
return cqrs.ErrUnexpectedRequest
}
ctn, err := container.From(ctx)
if err != nil {
return errors.Wrap(err, "could not retrieve service container")
}
db, err := storm.From(ctn)
if err != nil {
return errors.Wrap(err, "could not retrieve storm service")
}
sms := &model.SMS{
SentAt: time.Now(),
}
sms.Body = req.Body
sms.Recipient = req.Recipient
if err := db.Save(sms); err != nil {
return errors.Wrap(err, "could not save email")
}
return nil
}

81
internal/config/config.go Normal file
View File

@ -0,0 +1,81 @@
package config
import (
"io"
"io/ioutil"
"github.com/caarlos0/env/v6"
"github.com/pkg/errors"
"gopkg.in/yaml.v2"
)
type Config struct {
HTTP HTTPConfig `yaml:"http"`
Data DataConfig `yaml:"data"`
}
type HTTPConfig struct {
Address string `yaml:"address" env:"FAKESMS_HTTP_ADDRESS"`
TemplateDir string `yaml:"templateDir" env:"FAKESMS_HTTP_TEMPLATEDIR"`
PublicDir string `yaml:"publicDir" env:"FAKESMS_HTTP_PUBLICDIR"`
}
type DataConfig struct {
Path string `yaml:"path" env:"FAKESMS_DATA_PATH"`
}
// NewFromFile retrieves the configuration from the given file
func NewFromFile(filepath string) (*Config, error) {
config := NewDefault()
data, err := ioutil.ReadFile(filepath)
if err != nil {
return nil, errors.Wrapf(err, "could not read file '%s'", filepath)
}
if err := yaml.Unmarshal(data, config); err != nil {
return nil, errors.Wrapf(err, "could not unmarshal configuration")
}
return config, nil
}
func WithEnvironment(conf *Config) error {
if err := env.Parse(conf); err != nil {
return err
}
return nil
}
func NewDumpDefault() *Config {
config := NewDefault()
return config
}
func NewDefault() *Config {
return &Config{
HTTP: HTTPConfig{
Address: ":8080",
TemplateDir: "template",
PublicDir: "public",
},
Data: DataConfig{
Path: "fakesms.db",
},
}
}
func Dump(config *Config, w io.Writer) error {
data, err := yaml.Marshal(config)
if err != nil {
return errors.Wrap(err, "could not dump config")
}
if _, err := w.Write(data); err != nil {
return err
}
return nil
}

View File

@ -0,0 +1,9 @@
package config
import "gitlab.com/wpetit/goweb/service"
func ServiceProvider(config *Config) service.Provider {
return func(ctn *service.Container) (interface{}, error) {
return config, nil
}
}

View File

@ -0,0 +1,33 @@
package config
import (
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/service"
)
const ServiceName service.Name = "config"
// From retrieves the config service in the given container
func From(container *service.Container) (*Config, error) {
service, err := container.Service(ServiceName)
if err != nil {
return nil, errors.Wrapf(err, "error while retrieving '%s' service", ServiceName)
}
srv, ok := service.(*Config)
if !ok {
return nil, errors.Errorf("retrieved service is not a valid '%s' service", ServiceName)
}
return srv, nil
}
// Must retrieves the config service in the given container or panic otherwise
func Must(container *service.Container) *Config {
srv, err := From(container)
if err != nil {
panic(err)
}
return srv
}

12
internal/model/sms.go Normal file
View File

@ -0,0 +1,12 @@
package model
import "time"
type SMS struct {
ID int `storm:"id,increment"`
Body string
Seen bool `storm:"index"`
Recipient string
SentAt time.Time
Metadata map[string]interface{}
}

View File

@ -0,0 +1,133 @@
package query
import (
"context"
"strings"
"time"
"forge.cadoles.com/Cadoles/fake-sms/internal/model"
"forge.cadoles.com/Cadoles/fake-sms/internal/storm"
stormdb "github.com/asdine/storm/v3"
"github.com/asdine/storm/v3/q"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/cqrs"
"gitlab.com/wpetit/goweb/middleware/container"
)
type OutboxSearch struct {
Recipient string
Body string
After time.Time
Before time.Time
}
type GetOutboxRequest struct {
OrderBy string
Limit int
Skip int
Reverse bool
Search *OutboxSearch
}
type OutboxData struct {
Messages []*model.SMS
}
func HandleGetOutbox(ctx context.Context, qry cqrs.Query) (interface{}, error) {
req, ok := qry.Request().(*GetOutboxRequest)
if !ok {
return nil, cqrs.ErrUnexpectedRequest
}
ctn, err := container.From(ctx)
if err != nil {
return nil, errors.Wrap(err, "could not retrieve service container")
}
db, err := storm.From(ctn)
if err != nil {
return nil, errors.Wrap(err, "could not retrieve storm service")
}
messages := make([]*model.SMS, 0)
var query stormdb.Query
if req.Search != nil {
matchers := make([]q.Matcher, 0)
if req.Search.Body != "" {
matchers = append(matchers, q.Or(
q.Re("Body", req.Search.Body),
))
}
query = db.Select(matchers...)
} else {
query = db.Select()
}
if req.OrderBy != "" {
query = query.OrderBy(req.OrderBy)
} else {
query = query.OrderBy("SentAt").Reverse()
}
if req.Reverse {
query = query.Reverse()
}
if req.Limit != 0 {
query = query.Limit(req.Limit)
}
if req.Skip != 0 {
query = query.Limit(req.Skip)
}
if err := query.Find(&messages); err != nil {
if err == storm.ErrNotFound {
return &OutboxData{messages}, nil
}
return nil, errors.Wrap(err, "could not retrieve emails")
}
if req.Search == nil {
return &OutboxData{messages}, nil
}
filtered := make([]*model.SMS, 0, len(messages))
for _, sms := range messages {
match := true
if req.Search.Recipient != "" {
found := false
if strings.Contains(sms.Recipient, req.Search.Recipient) {
found = true
break
}
if !found {
match = false
}
}
if !req.Search.After.IsZero() && !sms.SentAt.After(req.Search.After) {
match = false
}
if !req.Search.Before.IsZero() && !sms.SentAt.Before(req.Search.Before) {
match = false
}
if match {
filtered = append(filtered, sms)
}
}
return &OutboxData{filtered}, nil
}

View File

@ -0,0 +1,44 @@
package query
import (
"context"
"forge.cadoles.com/Cadoles/fake-sms/internal/model"
"forge.cadoles.com/Cadoles/fake-sms/internal/storm"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/cqrs"
"gitlab.com/wpetit/goweb/middleware/container"
)
type OpenSMSRequest struct {
SMSID int
}
type OpenSMSData struct {
SMS *model.SMS
}
func HandleOpenSMS(ctx context.Context, qry cqrs.Query) (interface{}, error) {
req, ok := qry.Request().(*OpenSMSRequest)
if !ok {
return nil, cqrs.ErrUnexpectedRequest
}
ctn, err := container.From(ctx)
if err != nil {
return nil, errors.Wrap(err, "could not retrieve service container")
}
db, err := storm.From(ctn)
if err != nil {
return nil, errors.Wrap(err, "could not retrieve storm service")
}
sms := &model.SMS{}
if err := db.One("ID", req.SMSID, sms); err != nil {
return nil, errors.Wrap(err, "could not find email")
}
return &OpenSMSData{sms}, nil
}

64
internal/route/api.go Normal file
View File

@ -0,0 +1,64 @@
package route
import (
"net/http"
"forge.cadoles.com/Cadoles/fake-sms/internal/query"
"forge.cadoles.com/Cadoles/fake-sms/internal/storm"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/api"
"gitlab.com/wpetit/goweb/cqrs"
"gitlab.com/wpetit/goweb/logger"
"gitlab.com/wpetit/goweb/middleware/container"
)
func browseAPIV1SMS(w http.ResponseWriter, r *http.Request) {
ctn := container.Must(r.Context())
bus := cqrs.Must(ctn)
ctx := r.Context()
getInbox, err := createOutboxQueryFromRequest(r)
if err != nil {
logger.Error(ctx, "bad request", logger.E(err))
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
result, err := bus.Query(ctx, getInbox)
if err != nil {
panic(errors.Wrap(err, "could not retrieve inbox"))
}
inboxData, ok := result.Data().(*query.OutboxData)
if !ok {
panic(errors.New("unexpected data"))
}
api.DataResponse(w, http.StatusOK, inboxData)
}
func serveAPIV1SMS(w http.ResponseWriter, r *http.Request) {
smsID, err := getSMSID(r)
if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
ctx := r.Context()
email, err := openSMS(ctx, smsID)
if err != nil {
if errors.Is(err, storm.ErrNotFound) {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
panic(errors.Wrap(err, "could not open sms"))
}
api.DataResponse(w, http.StatusOK, email)
}

101
internal/route/helper.go Normal file
View File

@ -0,0 +1,101 @@
package route
import (
"net/http"
"strconv"
"time"
"forge.cadoles.com/Cadoles/fake-sms/internal/query"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/middleware/container"
"gitlab.com/wpetit/goweb/service/template"
)
func extendTemplateData(w http.ResponseWriter, r *http.Request, data template.Data) template.Data {
ctn := container.Must(r.Context())
data, err := template.Extend(data,
template.WithBuildInfo(w, r, ctn),
)
if err != nil {
panic(errors.Wrap(err, "could not extend template data"))
}
return data
}
func createOutboxQueryFromRequest(r *http.Request) (*query.GetOutboxRequest, error) {
orderBy := r.URL.Query().Get("orderBy")
reverse := r.URL.Query().Get("reverse")
recipient := r.URL.Query().Get("recipient")
body := r.URL.Query().Get("body")
var err error
var limit int64 = 0
rawLimit := r.URL.Query().Get("limit")
if rawLimit != "" {
limit, err = strconv.ParseInt(rawLimit, 10, 32)
if err != nil {
return nil, errors.WithStack(err)
}
}
var skip int64 = 0
rawSkip := r.URL.Query().Get("skip")
if rawSkip != "" {
skip, err = strconv.ParseInt(rawSkip, 10, 32)
if err != nil {
return nil, errors.WithStack(err)
}
}
var after time.Time
rawAfter := r.URL.Query().Get("after")
if rawAfter != "" {
after, err = time.Parse(time.RFC3339, rawAfter)
if err != nil {
return nil, errors.WithStack(err)
}
}
var before time.Time
rawBefore := r.URL.Query().Get("before")
if rawBefore != "" {
before, err = time.Parse(time.RFC3339, rawBefore)
if err != nil {
return nil, errors.WithStack(err)
}
}
search := &query.OutboxSearch{}
if recipient != "" {
search.Recipient = recipient
}
if body != "" {
search.Body = body
}
if rawAfter != "" {
search.After = after
}
if rawBefore != "" {
search.Before = before
}
inboxRequest := &query.GetOutboxRequest{
OrderBy: orderBy,
Reverse: reverse == "y",
Skip: int(skip),
Limit: int(limit),
Search: search,
}
return inboxRequest, nil
}

29
internal/route/mount.go Normal file
View File

@ -0,0 +1,29 @@
package route
import (
"forge.cadoles.com/Cadoles/fake-sms/internal/config"
"github.com/go-chi/chi"
"gitlab.com/wpetit/goweb/static"
)
func Mount(r *chi.Mux, config *config.Config) error {
r.Group(func(r chi.Router) {
r.Get("/", serveOutboxPage)
r.Delete("/sms", handleClearOutbox)
r.Get("/sms/{id}", serveSMSPage)
r.Delete("/sms/{id}", handleSMSDelete)
})
r.Route("/api", func(r chi.Router) {
r.Route("/v1", func(r chi.Router) {
r.Get("/sms", browseAPIV1SMS)
r.Get("/sms/{id}", serveAPIV1SMS)
})
})
notFoundHandler := r.NotFoundHandler()
r.Get("/*", static.Dir(config.HTTP.PublicDir, "", notFoundHandler))
return nil
}

62
internal/route/outbox.go Normal file
View File

@ -0,0 +1,62 @@
package route
import (
"net/http"
"forge.cadoles.com/Cadoles/fake-sms/internal/command"
"forge.cadoles.com/Cadoles/fake-sms/internal/query"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/cqrs"
"gitlab.com/wpetit/goweb/logger"
"gitlab.com/wpetit/goweb/middleware/container"
"gitlab.com/wpetit/goweb/service/template"
)
func serveOutboxPage(w http.ResponseWriter, r *http.Request) {
ctn := container.Must(r.Context())
tmpl := template.Must(ctn)
bus := cqrs.Must(ctn)
ctx := r.Context()
getOutbox, err := createOutboxQueryFromRequest(r)
if err != nil {
logger.Error(ctx, "bad request", logger.E(err))
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
result, err := bus.Query(ctx, getOutbox)
if err != nil {
panic(errors.Wrap(err, "could not retrieve outbox"))
}
inboxData, ok := result.Data().(*query.OutboxData)
if !ok {
panic(errors.New("unexpected data"))
}
data := extendTemplateData(w, r, template.Data{
"Messages": inboxData.Messages,
})
if err := tmpl.RenderPage(w, "outbox.html.tmpl", data); err != nil {
panic(errors.Wrapf(err, "could not render '%s' page", r.URL.Path))
}
}
func handleClearOutbox(w http.ResponseWriter, r *http.Request) {
ctn := container.Must(r.Context())
bus := cqrs.Must(ctn)
clearInbox := &command.ClearOutboxRequest{}
ctx := r.Context()
if _, err := bus.Exec(ctx, clearInbox); err != nil {
panic(errors.Wrap(err, "could not clear outbox"))
}
http.Error(w, http.StatusText(http.StatusNoContent), http.StatusNoContent)
}

115
internal/route/sms.go Normal file
View File

@ -0,0 +1,115 @@
package route
import (
"context"
"net/http"
"strconv"
"forge.cadoles.com/Cadoles/fake-sms/internal/command"
"forge.cadoles.com/Cadoles/fake-sms/internal/model"
"forge.cadoles.com/Cadoles/fake-sms/internal/query"
"forge.cadoles.com/Cadoles/fake-sms/internal/storm"
"github.com/go-chi/chi"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/cqrs"
"gitlab.com/wpetit/goweb/middleware/container"
"gitlab.com/wpetit/goweb/service/template"
)
func serveSMSPage(w http.ResponseWriter, r *http.Request) {
smsID, err := getSMSID(r)
if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
ctx := r.Context()
sms, err := openSMS(ctx, smsID)
if err != nil {
if errors.Is(err, storm.ErrNotFound) {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
panic(errors.Wrap(err, "could not open sms"))
}
ctn := container.Must(ctx)
tmpl := template.Must(ctn)
data := extendTemplateData(w, r, template.Data{
"SMS": sms,
})
if err := tmpl.RenderPage(w, "sms.html.tmpl", data); err != nil {
panic(errors.Wrapf(err, "could not render '%s' page", r.URL.Path))
}
}
func handleSMSDelete(w http.ResponseWriter, r *http.Request) {
smsID, err := getSMSID(r)
if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
ctx := r.Context()
ctn := container.Must(ctx)
bus := cqrs.Must(ctn)
deleteSMS := &command.DeleteSMSRequest{
SMSID: smsID,
}
if _, err := bus.Exec(ctx, deleteSMS); err != nil {
panic(errors.Wrap(err, "could not delete email"))
}
http.Error(w, http.StatusText(http.StatusNoContent), http.StatusNoContent)
}
func getSMSID(r *http.Request) (int, error) {
rawSMSID := chi.URLParam(r, "id")
smsID, err := strconv.ParseInt(rawSMSID, 10, 32)
if err != nil {
return 0, err
}
return int(smsID), nil
}
func openSMS(ctx context.Context, emailID int) (*model.SMS, error) {
ctn := container.Must(ctx)
bus := cqrs.Must(ctn)
req := &query.OpenSMSRequest{
SMSID: emailID,
}
result, err := bus.Query(ctx, req)
if err != nil {
return nil, err
}
openEmailData, ok := result.Data().(*query.OpenSMSData)
if !ok {
return nil, errors.New("unexpected result data")
}
return openEmailData.SMS, nil
}
func getAttachmentIndex(r *http.Request) (int, error) {
rawAttachmendIndex := chi.URLParam(r, "attachmendIndex")
attachmendIndex, err := strconv.ParseInt(rawAttachmendIndex, 10, 32)
if err != nil {
return 0, err
}
return int(attachmendIndex), nil
}

9
internal/storm/error.go Normal file
View File

@ -0,0 +1,9 @@
package storm
import (
"github.com/asdine/storm/v3"
)
var (
ErrNotFound = storm.ErrNotFound
)

51
internal/storm/option.go Normal file
View File

@ -0,0 +1,51 @@
package storm
type Option struct {
Path string
Objects []interface{}
ReIndex bool
Init bool
}
type OptionFunc func(*Option)
func DefaultOption() *Option {
return MergeOption(
&Option{},
WithPath("data.db"),
WithInit(true),
WithReIndex(true),
)
}
func MergeOption(opt *Option, funcs ...OptionFunc) *Option {
for _, fn := range funcs {
fn(opt)
}
return opt
}
func WithPath(path string) OptionFunc {
return func(opt *Option) {
opt.Path = path
}
}
func WithReIndex(reindex bool) OptionFunc {
return func(opt *Option) {
opt.ReIndex = reindex
}
}
func WithInit(init bool) OptionFunc {
return func(opt *Option) {
opt.Init = init
}
}
func WithObjects(objects ...interface{}) OptionFunc {
return func(opt *Option) {
opt.Objects = objects
}
}

View File

@ -0,0 +1,48 @@
package storm
import (
"reflect"
"github.com/asdine/storm/v3"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/service"
)
func ServiceProvider(funcs ...OptionFunc) service.Provider {
opt := MergeOption(
DefaultOption(),
funcs...,
)
db, err := storm.Open(opt.Path)
if err == nil && opt.Objects != nil {
err = migrate(db, opt.Objects, opt.Init, opt.ReIndex)
}
return func(ctn *service.Container) (interface{}, error) {
if err != nil {
return nil, err
}
return db, nil
}
}
func migrate(db *storm.DB, objects []interface{}, init, reindex bool) error {
for _, o := range objects {
if init {
if err := db.Init(o); err != nil {
return errors.Wrapf(err, "could not init object '%s'", reflect.TypeOf(o).String())
}
}
if reindex {
if err := db.ReIndex(o); err != nil {
return errors.Wrapf(err, "could not reindex object '%s'", reflect.TypeOf(o).String())
}
}
}
return nil
}

34
internal/storm/service.go Normal file
View File

@ -0,0 +1,34 @@
package storm
import (
"github.com/asdine/storm/v3"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/service"
)
const ServiceName service.Name = "storm"
// From retrieves the storm service in the given container
func From(container *service.Container) (*storm.DB, error) {
service, err := container.Service(ServiceName)
if err != nil {
return nil, errors.Wrapf(err, "error while retrieving '%s' service", ServiceName)
}
srv, ok := service.(*storm.DB)
if !ok {
return nil, errors.Errorf("retrieved service is not a valid '%s' service", ServiceName)
}
return srv, nil
}
// Must retrieves the storm service in the given container or panic otherwise
func Must(container *service.Container) *storm.DB {
srv, err := From(container)
if err != nil {
panic(err)
}
return srv
}