Add Iroquois/Powow API mocking entrypoint

This commit is contained in:
wpetit 2020-12-22 15:00:42 +01:00
parent d9a6c14041
commit 9b90eaf240
12 changed files with 358 additions and 91 deletions

1
.gitignore vendored
View File

@ -4,3 +4,4 @@
/bin
/node_modules
/.env
/.vscode

View File

@ -29,13 +29,36 @@ Voici la structure du fichier par défaut:
```yaml
# Configuration HTTP
http:
address: :8080
address: :3000
templateDir: template
publicDir: public
# Configuration du stockage
data:
path: fakesms.db
# Confirguration du mock Powow
# Voir https://powow4.iroquois.fr/
powow:
# Clé d'API à utiliser par les clients Powow utilisant le mock
apiKey: powow
# Modèles de SMS transactionnels
# Voir https://powow4.iroquois.fr/user/docs/api/#create-transactional-sms
# et https://powow4.iroquois.fr/user/docs/api/#update-transactional-sms
#
# L'identifiant (SmsID) de chaque modèle est son index dans le tableau.
sms:
- name: Powow SMS
from: FakeSMS
# Modèle de contenu pour le SMS avec patrons d'insertion
# Voir https://powow4.iroquois.fr/user/docs/api/#send-transactional-sms, "About the CustomData parameter"
content: |
Bonjour %Subscriber:Firstname%,
Lorem ipsum dolor sit amet...
# Cet attribut n'est pas utilisé dans le cadre du mock
shortLink: false
```
### Variables d'environnement
@ -50,6 +73,20 @@ Les valeurs des variables d'environnement surchargent les valeurs présentes dan
|`FAKESMS_HTTP_TEMPLATEDIR`|`http.templateDir`|
|`FAKESMS_HTTP_PUBLICDIR`|`http.publicDir`|
## Mocks
### Iroquois/Powow
Un mock de l'API Powow est disponible via l'URL `http://<fake_sms_host>/api/v1/mock/powow`.
Les commandes suivantes sont implémentées:
|Commande|Documentation officiele|Notes|
|--------|-----------------------|-----------|
|`TransactionalSms.Send`|https://powow4.iroquois.fr/user/docs/api/#send-transactional-sms|La définition des modèles de SMS s'effectue via la configuration.|
Un exemple d'appel à l'API est disponible dans le fichier [`misc/powow.http`](./misc/powow.http).
## Démarrer avec les sources
### Dépendances

View File

@ -1,6 +1,9 @@
package main
import (
"encoding/json"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/template/html"
"forge.cadoles.com/Cadoles/fake-sms/internal/command"
@ -22,6 +25,14 @@ func getServiceContainer(conf *config.Config) (*service.Container, error) {
// Create and expose template service provider
ctn.Provide(template.ServiceName, html.ServiceProvider(
html.NewDirectoryLoader(conf.HTTP.TemplateDir),
html.WithHelper("toPrettyJSON", func(data interface{}) (string, error) {
json, err := json.MarshalIndent(data, "", " ")
if err != nil {
return "", errors.WithStack(err)
}
return string(json), nil
}),
))
// Create and expose config service provider

View File

@ -16,8 +16,9 @@
<table class="outbox table is-fullwidth is-striped is-hoverable">
<thead>
<tr>
<th class="sms-to">Recipient</th>
<th class="sms-sentat">Date</th>
<th class="sms-from">From</th>
<th class="sms-recipient">Recipient</th>
<th class="sms-sentat">Sent At</th>
<th class="sms-actions"></th>
</tr>
</thead>
@ -26,10 +27,11 @@
<tr data-controller="inbox-entry"
data-action="click->outbox-entry#onClick"
data-inbox-entry-link="./sms/{{ .ID }}">
<td class="sms-from">
<span class="is-size-7">{{ .From }}</span>
</td>
<td class="sms-recipient">
{{range .Recipient}}
<span class="tag">{{ .Recipient }}</span>
{{end}}
</td>
<td class="sms-sentat">
<span class="is-size-7">{{ .SentAt.Format "02/01/2006 15:04:05"}}</span>

View File

@ -1,8 +1,8 @@
{{define "title"}}Email - FakeSMTP{{end}}
{{define "title"}}SMS - FakeSMTP{{end}}
{{define "header_buttons"}}
<button class="button is-danger"
data-controller="restful"
data-restful-endpoint="./{{ .Email.ID }}"
data-restful-endpoint="./{{ .SMS.ID }}"
data-restful-method="DELETE"
data-restful-redirect="../">
🗑️ Delete
@ -16,40 +16,21 @@
<div class="column">
<div class="columns">
<div class="column">
<h4 class="title is-size-4">Email</h4>
{{template "email_head" .}}
<h4 class="title is-size-4">SMS</h4>
{{template "sms_head" .}}
</div>
{{if .Email.Attachments}}
<div class="column is-narrow">
<h4 class="title is-size-4">Attachments ({{len .Email.Attachments}})</h4>
<ul>
{{ $email := .Email }}
{{range $i, $a := .Email.Attachments}}
<li><a href="{{ $email.ID }}/attachments/{{ $i }}" download="{{ $a.Name }}">{{ $a.Name }}</a></li>
{{end}}
</ul>
</div>
{{end}}
</div>
<div data-controller="tabs">
<div class="tabs">
<ul>
<li data-action="click->tabs#openTab" data-target="tabs.tab" data-tabs-name="html" {{if .Email.HTML}}class="is-active"{{end}}><a>HTML</a></li>
<li data-action="click->tabs#openTab" data-target="tabs.tab" data-tabs-name="text" {{if not .Email.HTML}}class="is-active"{{end}}><a>Text</a></li>
<li data-action="click->tabs#openTab" data-target="tabs.tab" data-tabs-name="headers"><a>Headers</a></li>
<li data-action="click->tabs#openTab" data-target="tabs.tab" data-tabs-name="text" class="is-active"><a>Body</a></li>
<li data-action="click->tabs#openTab" data-target="tabs.tab" data-tabs-name="metadata"><a>Metadata</a></li>
</ul>
</div>
<iframe data-target="tabs.tabContent" data-tabs-for="html"
frameborder="0"
data-controller="iframe"
data-action="load->iframe#onLoad"
style="width:100%;{{if not .Email.HTML}}display:none;{{end}}"
src="{{ .Email.ID }}/html">
</iframe>
<div data-target="tabs.tabContent" data-tabs-for="text" style="{{if .Email.HTML}}display:none;{{end}}width:100%;overflow:hidden;">
<pre style="white-space:pre-line;">{{ .Email.Text }}</pre>
<div data-target="tabs.tabContent" data-tabs-for="text" style="width:100%;overflow:hidden;">
<pre style="white-space:pre-line;">{{ .SMS.Body }}</pre>
</div>
<div data-target="tabs.tabContent" data-tabs-for="headers" style="display:none">
<div data-target="tabs.tabContent" data-tabs-for="metadata" style="display:none">
<div class="table-container">
<table class="table is-striped is-hoverable is-fullwidth">
<thead>
@ -59,13 +40,11 @@
<tr>
<thead>
<tbody>
{{range $k, $v := .Email.Headers}}
{{range $k, $v := .SMS.Metadata}}
<tr>
<td><code>{{ $k }}</code></td>
<td>
{{range $v}}
<code>{{ . }}</code>&nbsp;
{{end}}
<code>{{ toPrettyJSON . }}</code>&nbsp;
</td>
</tr>
{{end}}
@ -81,61 +60,30 @@
</div>
</section>
{{end}}
{{define "email_head"}}
{{define "sms_head"}}
<div class="columns">
<div class="column is-1">
<h5 class="is-size-5">From</h5>
</div>
<div class="column">
{{template "email_adresses" .Email.From}}
<span class="tag is-large">{{.SMS.From}}</span>
</div>
</div>
<div class="columns">
<div class="column is-1">
<h5 class="is-size-5">To</h5>
<h5 class="is-size-5">Recipient</h5>
</div>
<div class="column">
{{template "email_adresses" .Email.To}}
<span class="tag is-large">{{.SMS.Recipient}}</span>
</div>
</div>
{{if .Email.Cc }}
<div class="columns">
<div class="column is-1">
<h5 class="is-size-5">Cc</h5>
<h5 class="is-size-5">Sent At</h5>
</div>
<div class="column">
{{template "email_adresses" .Email.Cc}}
<p class="is-size-5">{{ .SMS.SentAt.Format "02/01/2006 15:04:05"}}</p>
</div>
</div>
{{end}}
{{if .Email.Cci }}
<div class="columns">
<div class="column is-1">
<h5 class="is-size-5">Cci</h5>
</div>
<div class="column">
{{template "email_adresses" .Email.Cci}}
</div>
</div>
{{end}}
<div class="columns">
<div class="column is-1">
<h5 class="is-size-5">Subject</h5>
</div>
<div class="column">
<p class="is-size-5">{{.Email.Subject}}</p>
</div>
</div>
{{end}}
{{define "email_adresses"}}
{{- range .}}
<span class="tag">
{{- if .Name -}}
{{.Name}} <{{.Address}}>
{{- else -}}
{{.Address}}
{{- end -}}
</span>
{{- end -}}
{{end}}
{{template "base" .}}

View File

@ -13,8 +13,10 @@ import (
)
type StoreSMSRequest struct {
From string
Body string
Recipient string
Metadata map[string]interface{}
}
func HandleStoreSMS(ctx context.Context, cmd cqrs.Command) error {
@ -39,6 +41,8 @@ func HandleStoreSMS(ctx context.Context, cmd cqrs.Command) error {
sms.Body = req.Body
sms.Recipient = req.Recipient
sms.Metadata = req.Metadata
sms.From = req.From
if err := db.Save(sms); err != nil {
return errors.Wrap(err, "could not save email")

View File

@ -13,6 +13,7 @@ import (
type Config struct {
HTTP HTTPConfig `yaml:"http"`
Data DataConfig `yaml:"data"`
Powow PowowConfig `ymal:"powow"`
}
type HTTPConfig struct {
@ -25,6 +26,18 @@ type DataConfig struct {
Path string `yaml:"path" env:"FAKESMS_DATA_PATH"`
}
type PowowConfig struct {
APIKey string `yaml:"apiKey" env:"FAKESMS_POWOW_API_KEY"`
SMS []PowowSMS `yaml:"sms"`
}
type PowowSMS struct {
Name string `yaml:"name"`
From string `yaml:"from"`
Content string `yaml:"content"`
ShortLink bool `yaml:"shortLink"`
}
// NewFromFile retrieves the configuration from the given file
func NewFromFile(filepath string) (*Config, error) {
config := NewDefault()
@ -57,13 +70,27 @@ func NewDumpDefault() *Config {
func NewDefault() *Config {
return &Config{
HTTP: HTTPConfig{
Address: ":8080",
Address: ":3000",
TemplateDir: "template",
PublicDir: "public",
},
Data: DataConfig{
Path: "fakesms.db",
},
Powow: PowowConfig{
APIKey: "powow",
SMS: []PowowSMS{
{
Name: "Powow SMS",
From: "FakeSMS",
ShortLink: false,
Content: `Bonjour %Subscriber:Firstname%,
Lorem ipsum dolor sit amet...
`,
},
},
},
}
}

View File

@ -6,6 +6,7 @@ type SMS struct {
ID int `storm:"id,increment"`
Body string
Seen bool `storm:"index"`
From string
Recipient string
SentAt time.Time
Metadata map[string]interface{}

View File

@ -19,7 +19,11 @@ func Mount(r *chi.Mux, config *config.Config) error {
r.Route("/v1", func(r chi.Router) {
r.Get("/sms", browseAPIV1SMS)
r.Get("/sms/{id}", serveAPIV1SMS)
// Powow Mock
r.Post("/mock/powow", handlePowowEntrypoint)
})
})
notFoundHandler := r.NotFoundHandler()

229
internal/route/powow.go Normal file
View File

@ -0,0 +1,229 @@
package route
import (
"context"
"encoding/base64"
"encoding/json"
"io/ioutil"
"net/http"
"strings"
"forge.cadoles.com/Cadoles/fake-sms/internal/command"
"forge.cadoles.com/Cadoles/fake-sms/internal/config"
"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"
)
type ErrorCode int
const (
ErrorCodeInvalidCommand ErrorCode = 99997
ErrorCodeAuthenticationFailure ErrorCode = 99998
ErrorCodeNotEnoughPrivileges ErrorCode = 99999
ErrorCodeMissingSMSID ErrorCode = 1
ErrorCodeMissingMobilePhoneNumber ErrorCode = 2
ErrorCodeInvalidSMSID ErrorCode = 3
ErrorCodeInvalidMobilePhoneNumber ErrorCode = 4
ErrorCodeInvalidCustomData ErrorCode = 5
ErrorCodeInvalidTimeToSend ErrorCode = 6
ErrorCodeAccountSubscribersLimitExceeded ErrorCode = 7
ErrorCodeMobilePhoneNumberCannotBeSaved ErrorCode = 7
ErrorCodeTransactionalIDCannotBeCreated ErrorCode = 9
ErrorCodeSMSSentLimitExceeded ErrorCode = 10
)
type Command string
const (
CommandTransactionalSMSSend = "TransactionalSms.Send"
)
type PowowRequest struct {
APIKey string `json:"ApiKey"`
Command Command
ResponseFormat string
Payload map[string]interface{}
}
type PowowResponse struct {
Success bool
ErrorCode ErrorCode
}
func handlePowowEntrypoint(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ctn := container.Must(ctx)
conf := config.Must(ctn)
data, err := ioutil.ReadAll(r.Body)
if err != nil {
logger.Error(ctx, "could not read body", logger.E(errors.WithStack(err)))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
defer r.Body.Close()
pr := &PowowRequest{
Payload: make(map[string]interface{}),
}
if err := json.Unmarshal(data, pr); err != nil {
logger.Error(ctx, "could not parse request", logger.E(errors.WithStack(err)))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if err := json.Unmarshal(data, &pr.Payload); err != nil {
logger.Error(ctx, "could not parse request payload", logger.E(errors.WithStack(err)))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
// Authenticate user
if conf.Powow.APIKey != pr.APIKey {
res := &PowowResponse{
Success: false,
ErrorCode: ErrorCodeAuthenticationFailure,
}
sendPowowResponse(w, res)
return
}
// Handle Powow command
switch pr.Command {
case CommandTransactionalSMSSend:
handleTransactionalSMSSend(ctx, ctn, w, pr)
return
default:
res := &PowowResponse{
Success: false,
ErrorCode: ErrorCodeInvalidCommand,
}
sendPowowResponse(w, res)
return
}
}
// Mock https://powow4.iroquois.fr/user/docs/api/#send-transactional-sms
func handleTransactionalSMSSend(ctx context.Context, ctn *service.Container, w http.ResponseWriter, req *PowowRequest) {
bus := cqrs.Must(ctn)
conf := config.Must(ctn)
rawSMSID, exists := req.Payload["SmsID"]
if !exists {
sendPowowResponse(w, &PowowResponse{
ErrorCode: ErrorCodeMissingSMSID,
Success: false,
})
return
}
smsID, ok := rawSMSID.(float64)
if !ok {
sendPowowResponse(w, &PowowResponse{
ErrorCode: ErrorCodeInvalidSMSID,
Success: false,
})
return
}
if smsID < 0 || int(smsID) > len(conf.Powow.SMS)-1 {
sendPowowResponse(w, &PowowResponse{
ErrorCode: ErrorCodeInvalidSMSID,
Success: false,
})
return
}
customData := make(map[string]interface{})
rawCustomData, exists := req.Payload["CustomData"]
if exists {
customData, ok = rawCustomData.(map[string]interface{})
if !ok {
sendPowowResponse(w, &PowowResponse{
ErrorCode: ErrorCodeInvalidCustomData,
Success: false,
})
return
}
}
sms := conf.Powow.SMS[int(smsID)]
body, err := createSMSBody(sms.Content, customData)
if err != nil {
panic(errors.Wrap(err, "could not generate sms body"))
}
req.Payload["_Template"] = sms
storeSMS := &command.StoreSMSRequest{
From: sms.From,
Body: body,
Recipient: req.Payload["MobilePhoneNumber"].(string),
Metadata: req.Payload,
}
_, err = bus.Exec(ctx, storeSMS)
if err != nil {
panic(errors.Wrap(err, "could not store sms"))
}
res := &struct {
PowowResponse
TransactionalID int
}{
PowowResponse: PowowResponse{
Success: true,
ErrorCode: 0,
},
TransactionalID: 0,
}
sendPowowResponse(w, res)
}
func createSMSBody(template string, customData map[string]interface{}) (string, error) {
content := template
for k, v := range customData {
decoded, err := base64.StdEncoding.DecodeString(v.(string))
if err != nil {
return "", errors.WithStack(err)
}
key := "%Subscriber:" + k + "%"
content = strings.ReplaceAll(content, key, string(decoded))
}
return content, nil
}
func sendPowowResponse(w http.ResponseWriter, res interface{}) {
w.Header().Add("Content-Type", "application/json")
encoder := json.NewEncoder(w)
encoder.SetIndent("", " ")
if err := encoder.Encode(res); err != nil {
panic(errors.WithStack(err))
}
}

View File

@ -102,14 +102,3 @@ func openSMS(ctx context.Context, emailID int) (*model.SMS, error) {
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
}

14
misc/powow.http Normal file
View File

@ -0,0 +1,14 @@
POST http://localhost:3000/api/v1/mock/powow
Content-Type: application/json
{
"APIKey": "powow",
"Command": "TransactionalSms.Send",
"ResponseFormat": "JSON",
"SmsID": 0,
"MobilePhoneNumber": "+33699999999",
"TimeToSend": "2017-01-01 10:00:00",
"CustomData": {
"Firstname": "Rm9v"
}
}