3
0

Implement actions & errors for notifications

This commit is contained in:
Denis Arh
2020-05-31 13:49:49 +02:00
parent 6c5953f973
commit ebff2fd6a6
5 changed files with 798 additions and 92 deletions

View File

@@ -2,77 +2,59 @@ package rest
import (
"context"
"fmt"
"github.com/cortezaproject/corteza-server/compose/rest/request"
"github.com/cortezaproject/corteza-server/compose/service"
"github.com/cortezaproject/corteza-server/pkg/mail"
"github.com/pkg/errors"
gomail "gopkg.in/mail.v2"
"github.com/cortezaproject/corteza-server/compose/types"
)
var _ = errors.Wrap
type (
Notification struct {
notification notificationService
svc notificationService
}
contentPayload struct {
Plain string `json:"plain"`
Html string `json:"html"`
HTML string `json:"html"`
}
notificationService interface {
SendEmail(context.Context, *gomail.Message) error
AttachEmailRecipients(context.Context, *gomail.Message, string, ...string) error
AttachRemoteFiles(context.Context, *gomail.Message, ...string) error
SendEmail(context.Context, *types.EmailNotification) error
}
)
func (Notification) New() *Notification {
return &Notification{
notification: service.DefaultNotification,
svc: service.DefaultNotification,
}
}
// EmailSend assembles Email Message and pushes message to notification service
func (ctrl *Notification) EmailSend(ctx context.Context, r *request.NotificationEmailSend) (interface{}, error) {
ntf := ctrl.notification
msg := mail.New()
if err := ntf.AttachEmailRecipients(ctx, msg, "To", r.To...); err != nil {
return false, err
}
if err := ntf.AttachEmailRecipients(ctx, msg, "Cc", r.Cc...); err != nil {
return false, err
ntf := &types.EmailNotification{
To: r.To,
Cc: r.Cc,
ReplyTo: r.ReplyTo,
Subject: r.Subject,
RemoteAttachments: r.RemoteAttachments,
}
var cp = contentPayload{}
if err := r.Content.Unmarshal(&cp); err != nil {
return false, errors.Wrap(err, "Could not unmarshal content")
return false, fmt.Errorf("could not unmarshal content: %w", err)
} else {
if len(cp.Html) > 0 {
msg.SetBody("text/html", cp.Html)
if len(cp.HTML) > 0 {
ntf.ContentHTML = cp.HTML
}
if len(cp.Plain) > 0 {
msg.SetBody("text/plain", cp.Plain)
ntf.ContentPlain = cp.Plain
}
}
msg.SetHeader("Subject", r.Subject)
if len(r.RemoteAttachments) > 0 {
err := ntf.AttachRemoteFiles(ctx, msg, r.RemoteAttachments...)
if err != nil {
return nil, err
}
}
if err := ntf.SendEmail(ctx, msg); err != nil {
if err := ctrl.svc.SendEmail(ctx, ntf); err != nil {
return false, err
} else {
return true, nil

View File

@@ -2,152 +2,207 @@ package service
import (
"context"
"fmt"
"net/http"
"path"
"strconv"
"strings"
"sync"
"github.com/pkg/errors"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
gomail "gopkg.in/mail.v2"
"github.com/cortezaproject/corteza-server/compose/types"
"github.com/cortezaproject/corteza-server/pkg/actionlog"
httpClient "github.com/cortezaproject/corteza-server/pkg/http"
"github.com/cortezaproject/corteza-server/pkg/logger"
"github.com/cortezaproject/corteza-server/pkg/mail"
"github.com/cortezaproject/corteza-server/system/types"
systemTypes "github.com/cortezaproject/corteza-server/system/types"
)
type (
// notification is a service that relays notification to receipients
// currently, we only support email notifications
//
// @todo due to initial architectural decisions, this service landed under compose
// but should be moved to system
// Warning: API endpoints on compose should be kept so that we do not break backward compatibility)
notification struct {
logger *zap.Logger
users notificationUserFinder
actionlog actionlog.Recorder
users notificationUserFinder
}
notificationUserFinder interface {
FindByID(context.Context, uint64) (*types.User, error)
FindByID(context.Context, uint64) (*systemTypes.User, error)
}
)
func Notification() *notification {
return &notification{
logger: DefaultLogger.Named("notification"),
users: DefaultSystemUser,
actionlog: DefaultActionlog,
users: DefaultSystemUser,
}
}
// log() returns zap's logger with requestID from current context and fields.
func (svc notification) log(ctx context.Context, fields ...zapcore.Field) *zap.Logger {
return logger.AddRequestID(ctx, svc.logger).With(fields...)
// SendEmail sends email notification
func (svc notification) SendEmail(ctx context.Context, n *types.EmailNotification) (err error) {
var (
aProps = &notificationActionProps{mail: n}
)
err = func() error {
msg := mail.New()
if len(n.To) == 0 {
return NotificationErrNoRecipients()
}
if err = svc.procEmailRecipients(ctx, msg, "To", n.To...); err != nil {
return err
}
if err = svc.procEmailRecipients(ctx, msg, "Cc", n.Cc...); err != nil {
return err
}
if len(n.ReplyTo) > 0 {
// extra check for length because ReplyTo can only hold 1 address!
if err = svc.procEmailRecipients(ctx, msg, "ReplyTo", n.ReplyTo); err != nil {
return err
}
}
msg.SetHeader("Subject", n.Subject)
if len(n.ContentHTML) > 0 {
msg.SetBody("text/html", n.ContentHTML)
}
if len(n.ContentPlain) > 0 || len(n.ContentHTML) == 0 {
// Make sure plain body is always set, even if empty
msg.SetBody("text/plain", n.ContentPlain)
}
if err = svc.procEmailAttachments(ctx, msg, n.RemoteAttachments...); err != nil {
return err
}
return mail.Send(msg)
}()
return svc.recordAction(ctx, aProps, NotificationActionSend, err)
}
func (svc notification) SendEmail(ctx context.Context, message *gomail.Message) error {
return mail.Send(message)
}
// AttachEmailRecipients validates, resolves, formats and attaches set of recipients to message
// procEmailRecipients validates, resolves, formats and attaches set of recipients to message
//
// Supports 3 input formats:
// - <valid email>
// - <valid email><space><name...>
// - <userID>
// Last one is then translated into valid email + name (when/if possible)
func (svc notification) AttachEmailRecipients(ctx context.Context, message *gomail.Message, field string, recipients ...string) (err error) {
func (svc notification) procEmailRecipients(ctx context.Context, m *gomail.Message, field string, rr ...string) (err error) {
var (
email string
name string
)
if len(recipients) == 0 {
if len(rr) == 0 {
return
}
for r, rcpt := range recipients {
for r, rcpt := range rr {
aProps := &notificationActionProps{recipient: rcpt}
name, email = "", ""
rcpt = strings.TrimSpace(rcpt)
if userID, err := strconv.ParseUint(rcpt, 10, 64); err == nil && userID > 0 {
// handle user ID
// proc <user ID>
if user, err := svc.users.FindByID(ctx, userID); err != nil {
return errors.Wrap(err, "could not get notification address")
return NotificationErrFailedToLoadUser(aProps).Wrap(err)
} else {
email = user.Email
name = user.Name
}
} else if spaceAt := strings.Index(rcpt, " "); spaceAt > -1 {
// handle: <email> <name> ("foo@bar.baz foo baz")
// proc <email> <name> ("foo@bar.baz foo baz")
email, name = rcpt[:spaceAt], strings.TrimSpace(rcpt[spaceAt+1:])
} else {
// handle: <email>
// proc <email>
email = rcpt
}
// Validate email here
if !mail.IsValidAddress(email) {
return errors.New("Invalid recipient email format")
return NotificationErrInvalidReceipientFormat(aProps)
}
recipients[r] = message.FormatAddress(email, name)
rr[r] = m.FormatAddress(email, name)
}
message.SetHeader(field, recipients...)
return
m.SetHeader(field, rr...)
return nil
}
func (svc notification) AttachRemoteFiles(ctx context.Context, message *gomail.Message, rr ...string) error {
// procEmailAttachments treats given strings (URLs) as remote files, downloads and attaches them to
// the message
//
// This could/should be easily extended to support data URLs as well
// see https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs
func (svc notification) procEmailAttachments(ctx context.Context, message *gomail.Message, aa ...string) error {
var (
wg = &sync.WaitGroup{}
// threading safely (when multiple files are to be attached)
l = &sync.Mutex{}
wg = &sync.WaitGroup{}
client, err = httpClient.New(&httpClient.Config{
Timeout: 10,
})
log = svc.logger
)
log.Debug("attaching files to mail notification", zap.Strings("urls", rr))
if err != nil {
return errors.WithStack(err)
return err
}
get := func(log *zap.Logger, req *http.Request) {
defer wg.Done()
get := func(url string) error {
aProps := &notificationActionProps{attachmentURL: url}
resp, err := client.Do(req)
req, err := client.Get(url)
if err != nil {
log.Error("could not send request to download remote attachment", zap.Error(err))
return
return err
}
resp, err := client.Do(req.WithContext(ctx))
if err != nil {
return NotificationErrFailedToDownloadAttachment(aProps).Wrap(err)
}
aProps.setAttachmentType(resp.Header.Get("Content-Type"))
aProps.setAttachmentSize(resp.ContentLength)
if resp.StatusCode != http.StatusOK {
log.Error("could not download remote attachment", zap.String("status", resp.Status))
return
return NotificationErrFailedToDownloadAttachment(aProps).
Wrap(fmt.Errorf("unexpected HTTP status: %s", resp.Status))
}
log.Info("download successful",
zap.Int64("content-length", resp.ContentLength),
zap.String("content-type", resp.Header.Get("Content-Type")),
)
l.Lock()
defer l.Unlock()
message.AttachReader(path.Base(req.URL.Path), resp.Body)
return nil
}
for _, url := range rr {
log := log.With(zap.String("remote-file", url))
req, err := client.Get(url)
if err != nil {
return errors.WithStack(err)
}
for _, url := range aa {
wg.Add(1)
go get(log, req)
go func() {
defer wg.Done()
_ = svc.recordAction(
ctx,
&notificationActionProps{attachmentURL: url},
NotificationActionAttachmentDownload,
get(url),
)
}()
}
wg.Wait()

View File

@@ -0,0 +1,612 @@
package service
// This file is auto-generated from compose/service/notifications_actions.yaml
//
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/cortezaproject/corteza-server/compose/types"
"github.com/cortezaproject/corteza-server/pkg/actionlog"
)
type (
notificationActionProps struct {
mail *types.EmailNotification
recipient string
attachmentURL string
attachmentSize int64
attachmentType string
}
notificationAction struct {
timestamp time.Time
resource string
action string
log string
severity actionlog.Severity
// prefix for error when action fails
errorMessage string
props *notificationActionProps
}
notificationError struct {
timestamp time.Time
error string
resource string
action string
message string
log string
severity actionlog.Severity
wrap error
props *notificationActionProps
}
)
var (
// just a placeholder to cover template cases w/o fmt package use
_ = fmt.Println
)
// *********************************************************************************************************************
// *********************************************************************************************************************
// Props methods
// setMail updates notificationActionProps's mail
//
// Allows method chaining
//
// This function is auto-generated.
//
func (p *notificationActionProps) setMail(mail *types.EmailNotification) *notificationActionProps {
p.mail = mail
return p
}
// setRecipient updates notificationActionProps's recipient
//
// Allows method chaining
//
// This function is auto-generated.
//
func (p *notificationActionProps) setRecipient(recipient string) *notificationActionProps {
p.recipient = recipient
return p
}
// setAttachmentURL updates notificationActionProps's attachmentURL
//
// Allows method chaining
//
// This function is auto-generated.
//
func (p *notificationActionProps) setAttachmentURL(attachmentURL string) *notificationActionProps {
p.attachmentURL = attachmentURL
return p
}
// setAttachmentSize updates notificationActionProps's attachmentSize
//
// Allows method chaining
//
// This function is auto-generated.
//
func (p *notificationActionProps) setAttachmentSize(attachmentSize int64) *notificationActionProps {
p.attachmentSize = attachmentSize
return p
}
// setAttachmentType updates notificationActionProps's attachmentType
//
// Allows method chaining
//
// This function is auto-generated.
//
func (p *notificationActionProps) setAttachmentType(attachmentType string) *notificationActionProps {
p.attachmentType = attachmentType
return p
}
// serialize converts notificationActionProps to actionlog.Meta
//
// This function is auto-generated.
//
func (p notificationActionProps) serialize() actionlog.Meta {
var (
m = make(actionlog.Meta)
)
if p.mail != nil {
m.Set("mail.subject", p.mail.Subject, true)
m.Set("mail.to", p.mail.To, true)
m.Set("mail.cc", p.mail.Cc, true)
m.Set("mail.replyTo", p.mail.ReplyTo, true)
m.Set("mail.contentPlain", p.mail.ContentPlain, true)
m.Set("mail.contentHTML", p.mail.ContentHTML, true)
m.Set("mail.remoteAttachments", p.mail.RemoteAttachments, true)
}
m.Set("recipient", p.recipient, true)
m.Set("attachmentURL", p.attachmentURL, true)
m.Set("attachmentSize", p.attachmentSize, true)
m.Set("attachmentType", p.attachmentType, true)
return m
}
// tr translates string and replaces meta value placeholder with values
//
// This function is auto-generated.
//
func (p notificationActionProps) tr(in string, err error) string {
var (
pairs = []string{"{err}"}
// first non-empty string
fns = func(ii ...interface{}) string {
for _, i := range ii {
if s := fmt.Sprintf("%v", i); len(s) > 0 {
return s
}
}
return ""
}
)
if err != nil {
for {
// Unwrap errors
ue := errors.Unwrap(err)
if ue == nil {
break
}
err = ue
}
pairs = append(pairs, err.Error())
} else {
pairs = append(pairs, "nil")
}
if p.mail != nil {
// replacement for "{mail}" (in order how fields are defined)
pairs = append(
pairs,
"{mail}",
fns(
p.mail.Subject,
p.mail.To,
p.mail.Cc,
p.mail.ReplyTo,
p.mail.ContentPlain,
p.mail.ContentHTML,
p.mail.RemoteAttachments,
),
)
pairs = append(pairs, "{mail.subject}", fns(p.mail.Subject))
pairs = append(pairs, "{mail.to}", fns(p.mail.To))
pairs = append(pairs, "{mail.cc}", fns(p.mail.Cc))
pairs = append(pairs, "{mail.replyTo}", fns(p.mail.ReplyTo))
pairs = append(pairs, "{mail.contentPlain}", fns(p.mail.ContentPlain))
pairs = append(pairs, "{mail.contentHTML}", fns(p.mail.ContentHTML))
pairs = append(pairs, "{mail.remoteAttachments}", fns(p.mail.RemoteAttachments))
}
pairs = append(pairs, "{recipient}", fns(p.recipient))
pairs = append(pairs, "{attachmentURL}", fns(p.attachmentURL))
pairs = append(pairs, "{attachmentSize}", fns(p.attachmentSize))
pairs = append(pairs, "{attachmentType}", fns(p.attachmentType))
return strings.NewReplacer(pairs...).Replace(in)
}
// *********************************************************************************************************************
// *********************************************************************************************************************
// Action methods
// String returns loggable description as string
//
// This function is auto-generated.
//
func (a *notificationAction) String() string {
var props = &notificationActionProps{}
if a.props != nil {
props = a.props
}
return props.tr(a.log, nil)
}
func (e *notificationAction) LoggableAction() *actionlog.Action {
return &actionlog.Action{
Timestamp: e.timestamp,
Resource: e.resource,
Action: e.action,
Severity: e.severity,
Description: e.String(),
Meta: e.props.serialize(),
}
}
// *********************************************************************************************************************
// *********************************************************************************************************************
// Error methods
// String returns loggable description as string
//
// It falls back to message if log is not set
//
// This function is auto-generated.
//
func (e *notificationError) String() string {
var props = &notificationActionProps{}
if e.props != nil {
props = e.props
}
if e.wrap != nil && !strings.Contains(e.log, "{err}") {
// Suffix error log with {err} to ensure
// we log the cause for this error
e.log += ": {err}"
}
return props.tr(e.log, e.wrap)
}
// Error satisfies
//
// This function is auto-generated.
//
func (e *notificationError) Error() string {
var props = &notificationActionProps{}
if e.props != nil {
props = e.props
}
return props.tr(e.message, e.wrap)
}
// Is fn for error equality check
//
// This function is auto-generated.
//
func (e *notificationError) Is(Resource error) bool {
t, ok := Resource.(*notificationError)
if !ok {
return false
}
return t.resource == e.resource && t.error == e.error
}
// Wrap wraps notificationError around another error
//
// This function is auto-generated.
//
func (e *notificationError) Wrap(err error) *notificationError {
e.wrap = err
return e
}
// Unwrap returns wrapped error
//
// This function is auto-generated.
//
func (e *notificationError) Unwrap() error {
return e.wrap
}
func (e *notificationError) LoggableAction() *actionlog.Action {
return &actionlog.Action{
Timestamp: e.timestamp,
Resource: e.resource,
Action: e.action,
Severity: e.severity,
Description: e.String(),
Error: e.Error(),
Meta: e.props.serialize(),
}
}
// *********************************************************************************************************************
// *********************************************************************************************************************
// Action constructors
// NotificationActionSend returns "compose:notification.send" error
//
// This function is auto-generated.
//
func NotificationActionSend(props ...*notificationActionProps) *notificationAction {
a := &notificationAction{
timestamp: time.Now(),
resource: "compose:notification",
action: "send",
log: "notification sent",
severity: actionlog.Info,
}
if len(props) > 0 {
a.props = props[0]
}
return a
}
// NotificationActionAttachmentDownload returns "compose:notification.attachmentDownload" error
//
// This function is auto-generated.
//
func NotificationActionAttachmentDownload(props ...*notificationActionProps) *notificationAction {
a := &notificationAction{
timestamp: time.Now(),
resource: "compose:notification",
action: "attachmentDownload",
log: "attachment downloaded",
severity: actionlog.Debug,
}
if len(props) > 0 {
a.props = props[0]
}
return a
}
// *********************************************************************************************************************
// *********************************************************************************************************************
// Error constructors
// NotificationErrGeneric returns "compose:notification.generic" audit event as actionlog.Error
//
//
// This function is auto-generated.
//
func NotificationErrGeneric(props ...*notificationActionProps) *notificationError {
var e = &notificationError{
timestamp: time.Now(),
resource: "compose:notification",
error: "generic",
action: "error",
message: "failed to complete request due to internal error",
log: "{err}",
severity: actionlog.Error,
props: func() *notificationActionProps {
if len(props) > 0 {
return props[0]
}
return nil
}(),
}
if len(props) > 0 {
e.props = props[0]
}
return e
}
// NotificationErrFailedToLoadUser returns "compose:notification.failedToLoadUser" audit event as actionlog.Error
//
//
// This function is auto-generated.
//
func NotificationErrFailedToLoadUser(props ...*notificationActionProps) *notificationError {
var e = &notificationError{
timestamp: time.Now(),
resource: "compose:notification",
error: "failedToLoadUser",
action: "error",
message: "could not load user for {recipient}",
log: "could not load user for {recipient}",
severity: actionlog.Error,
props: func() *notificationActionProps {
if len(props) > 0 {
return props[0]
}
return nil
}(),
}
if len(props) > 0 {
e.props = props[0]
}
return e
}
// NotificationErrInvalidReceipientFormat returns "compose:notification.invalidReceipientFormat" audit event as actionlog.Error
//
//
// This function is auto-generated.
//
func NotificationErrInvalidReceipientFormat(props ...*notificationActionProps) *notificationError {
var e = &notificationError{
timestamp: time.Now(),
resource: "compose:notification",
error: "invalidReceipientFormat",
action: "error",
message: "invalid recipient format ({recipient})",
log: "invalid recipient format ({recipient})",
severity: actionlog.Error,
props: func() *notificationActionProps {
if len(props) > 0 {
return props[0]
}
return nil
}(),
}
if len(props) > 0 {
e.props = props[0]
}
return e
}
// NotificationErrNoRecipients returns "compose:notification.noRecipients" audit event as actionlog.Error
//
//
// This function is auto-generated.
//
func NotificationErrNoRecipients(props ...*notificationActionProps) *notificationError {
var e = &notificationError{
timestamp: time.Now(),
resource: "compose:notification",
error: "noRecipients",
action: "error",
message: "can not send email message without recipients",
log: "can not send email message without recipients",
severity: actionlog.Error,
props: func() *notificationActionProps {
if len(props) > 0 {
return props[0]
}
return nil
}(),
}
if len(props) > 0 {
e.props = props[0]
}
return e
}
// NotificationErrFailedToDownloadAttachment returns "compose:notification.failedToDownloadAttachment" audit event as actionlog.Error
//
//
// This function is auto-generated.
//
func NotificationErrFailedToDownloadAttachment(props ...*notificationActionProps) *notificationError {
var e = &notificationError{
timestamp: time.Now(),
resource: "compose:notification",
error: "failedToDownloadAttachment",
action: "error",
message: "could not download attachment from {attachmentURL}: {err}",
log: "could not download attachment from {attachmentURL}: {err}",
severity: actionlog.Error,
props: func() *notificationActionProps {
if len(props) > 0 {
return props[0]
}
return nil
}(),
}
if len(props) > 0 {
e.props = props[0]
}
return e
}
// *********************************************************************************************************************
// *********************************************************************************************************************
// recordAction is a service helper function wraps function that can return error
//
// context is used to enrich audit log entry with current user info, request ID, IP address...
// props are collected action/error properties
// action (optional) fn will be used to construct notificationAction struct from given props (and error)
// err is any error that occurred while action was happening
//
// Action has success and fail (error) state:
// - when recorded without an error (4th param), action is recorded as successful.
// - when an additional error is given (4th param), action is used to wrap
// the additional error
//
// This function is auto-generated.
//
func (svc notification) recordAction(ctx context.Context, props *notificationActionProps, action func(...*notificationActionProps) *notificationAction, err error) error {
var (
ok bool
// Return error
retError *notificationError
// Recorder error
recError *notificationError
)
if err != nil {
if retError, ok = err.(*notificationError); !ok {
// got non-notification error, wrap it with NotificationErrGeneric
retError = NotificationErrGeneric(props).Wrap(err)
if action != nil {
// copy action to returning and recording error
retError.action = action().action
}
// we'll use NotificationErrGeneric for recording too
// because it can hold more info
recError = retError
} else if retError != nil {
if action != nil {
// copy action to returning and recording error
retError.action = action().action
}
// start with copy of return error for recording
// this will be updated with tha root cause as we try and
// unwrap the error
recError = retError
// find the original recError for this error
// for the purpose of logging
var unwrappedError error = retError
for {
if unwrappedError = errors.Unwrap(unwrappedError); unwrappedError == nil {
// nothing wrapped
break
}
// update recError ONLY of wrapped error is of type notificationError
if unwrappedSinkError, ok := unwrappedError.(*notificationError); ok {
recError = unwrappedSinkError
}
}
if retError.props == nil {
// set props on returning error if empty
retError.props = props
}
if recError.props == nil {
// set props on recording error if empty
recError.props = props
}
}
}
if svc.actionlog != nil {
if retError != nil {
// failed action, log error
svc.actionlog.Record(ctx, recError)
} else if action != nil {
// successful
svc.actionlog.Record(ctx, action(props))
}
}
if err == nil {
// retError not an interface and that WILL (!!) cause issues
// with nil check (== nil) when it is not explicitly returned
return nil
}
return retError
}

View File

@@ -0,0 +1,44 @@
# List of loggable service actions
resource: compose:notification
service: notification
# Default sensitivity for actions
defaultActionSeverity: info
# default severity for errors
defaultErrorSeverity: error
import:
- github.com/cortezaproject/corteza-server/compose/types
props:
- name: mail
type: "*types.EmailNotification"
fields: [ subject, to, cc, replyTo, contentPlain, contentHTML, remoteAttachments ]
- name: recipient
- name: attachmentURL
- name: attachmentSize
type: int64
- name: attachmentType
actions:
- action: send
log: "notification sent"
- action: attachmentDownload
log: "attachment downloaded"
severity: debug
errors:
- error: failedToLoadUser
message: "could not load user for {recipient}"
- error: invalidReceipientFormat
message: "invalid recipient format ({recipient})"
- error: noRecipients
message: "can not send email message without recipients"
- error: failedToDownloadAttachment
message: "could not download attachment from {attachmentURL}: {err}"

View File

@@ -0,0 +1,13 @@
package types
type (
EmailNotification struct {
To []string
Cc []string
ReplyTo string
Subject string
ContentPlain string
ContentHTML string
RemoteAttachments []string
}
)