commit 4735da98879872e4c8117af085d00d5261f60572 Author: lash Date: Sun Jan 5 07:19:45 2025 +0000 Fix storage related compile problems diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..dfc745f --- /dev/null +++ b/go.mod @@ -0,0 +1,28 @@ +module git.grassecon.net/grassrootseconomics/visedriver-africastalking + +go 1.23.0 + +require ( + git.defalsify.org/vise.git v0.2.3-0.20250103172917-3e190a44568d + git.grassecon.net/urdt/ussd v0.8.0-beta.5.0.20250104211339-51b6fc0dde43 +) + +require ( + github.com/alecthomas/participle/v2 v2.0.0 // indirect + github.com/barbashov/iso639-3 v0.0.0-20211020172741-1f4ffb2d8d1c // indirect + github.com/fxamacker/cbor/v2 v2.4.0 // indirect + github.com/grassrootseconomics/eth-custodial v1.3.0-beta // indirect + github.com/grassrootseconomics/ussd-data-service v1.2.0-beta // indirect + github.com/graygnuorg/go-gdbm v0.0.0-20220711140707-71387d66dce4 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.7.1 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/joho/godotenv v1.5.1 // indirect + github.com/mattn/kinako v0.0.0-20170717041458-332c0a7e205a // indirect + github.com/x448/float16 v0.8.4 // indirect + golang.org/x/crypto v0.27.0 // indirect + golang.org/x/sync v0.8.0 // indirect + golang.org/x/text v0.18.0 // indirect + gopkg.in/leonelquinteros/gotext.v1 v1.3.1 // indirect +) diff --git a/internal/africastalking/parse.go b/internal/africastalking/parse.go new file mode 100644 index 0000000..894d9dc --- /dev/null +++ b/internal/africastalking/parse.go @@ -0,0 +1,121 @@ +package at + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "git.grassecon.net/urdt/ussd/common" + "git.grassecon.net/urdt/ussd/errors" +) + +type ATRequestParser struct { + Context context.Context +} + +func (arp *ATRequestParser) GetSessionId(rq any) (string, error) { + rqv, ok := rq.(*http.Request) + if !ok { + logg.Warnf("got an invalid request", "req", rq) + return "", errors.ErrInvalidRequest + } + + // Capture body (if any) for logging + body, err := io.ReadAll(rqv.Body) + if err != nil { + logg.Warnf("failed to read request body", "err", err) + return "", fmt.Errorf("failed to read request body: %v", err) + } + // Reset the body for further reading + rqv.Body = io.NopCloser(bytes.NewReader(body)) + + // Log the body as JSON + bodyLog := map[string]string{"body": string(body)} + logBytes, err := json.Marshal(bodyLog) + if err != nil { + logg.Warnf("failed to marshal request body", "err", err) + } else { + decodedStr := string(logBytes) + sessionId, err := extractATSessionId(decodedStr) + if err != nil { + context.WithValue(arp.Context, "at-session-id", sessionId) + } + logg.Debugf("Received request:", decodedStr) + } + + if err := rqv.ParseForm(); err != nil { + logg.Warnf("failed to parse form data", "err", err) + return "", fmt.Errorf("failed to parse form data: %v", err) + } + + phoneNumber := rqv.FormValue("phoneNumber") + if phoneNumber == "" { + return "", fmt.Errorf("no phone number found") + } + + formattedNumber, err := common.FormatPhoneNumber(phoneNumber) + if err != nil { + logg.Warnf("failed to format phone number", "err", err) + return "", fmt.Errorf("failed to format number") + } + + return formattedNumber, nil +} + +func (arp *ATRequestParser) GetInput(rq any) ([]byte, error) { + rqv, ok := rq.(*http.Request) + if !ok { + return nil, errors.ErrInvalidRequest + } + if err := rqv.ParseForm(); err != nil { + return nil, fmt.Errorf("failed to parse form data: %v", err) + } + + text := rqv.FormValue("text") + + parts := strings.Split(text, "*") + if len(parts) == 0 { + return nil, fmt.Errorf("no input found") + } + + return []byte(parts[len(parts)-1]), nil +} + +func parseQueryParams(query string) map[string]string { + params := make(map[string]string) + + queryParams := strings.Split(query, "&") + for _, param := range queryParams { + // Split each key-value pair by '=' + parts := strings.SplitN(param, "=", 2) + if len(parts) == 2 { + params[parts[0]] = parts[1] + } + } + return params +} + +func extractATSessionId(decodedStr string) (string, error) { + var data map[string]string + err := json.Unmarshal([]byte(decodedStr), &data) + + if err != nil { + logg.Errorf("Error unmarshalling JSON: %v", err) + return "", nil + } + decodedBody, err := url.QueryUnescape(data["body"]) + if err != nil { + logg.Errorf("Error URL-decoding body: %v", err) + return "", nil + } + params := parseQueryParams(decodedBody) + + sessionId := params["sessionId"] + return sessionId, nil + +} diff --git a/internal/africastalking/server.go b/internal/africastalking/server.go new file mode 100644 index 0000000..8ccde3e --- /dev/null +++ b/internal/africastalking/server.go @@ -0,0 +1,98 @@ +package at + +import ( + "io" + "net/http" + + "git.defalsify.org/vise.git/logging" + "git.grassecon.net/urdt/ussd/request" + "git.grassecon.net/urdt/ussd/errors" +) + +var ( + logg = logging.NewVanilla().WithDomain("atserver") +) + +type ATSessionHandler struct { + *request.SessionHandler +} + +func NewATSessionHandler(h request.RequestHandler) *ATSessionHandler { + return &ATSessionHandler{ + SessionHandler: request.ToSessionHandler(h), + } +} + +func (ash *ATSessionHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { + var code int + var err error + + rqs := request.RequestSession{ + Ctx: req.Context(), + Writer: w, + } + + rp := ash.GetRequestParser() + cfg := ash.GetConfig() + cfg.SessionId, err = rp.GetSessionId(req) + if err != nil { + logg.ErrorCtxf(rqs.Ctx, "", "header processing error", err) + ash.WriteError(w, 400, err) + return + } + rqs.Config = cfg + rqs.Input, err = rp.GetInput(req) + if err != nil { + logg.ErrorCtxf(rqs.Ctx, "", "header processing error", err) + ash.WriteError(w, 400, err) + return + } + + rqs, err = ash.Process(rqs) + switch err { + case nil: // set code to 200 if no err + code = 200 + case errors.ErrStorage, errors.ErrEngineInit, errors.ErrEngineExec, errors.ErrEngineType: + code = 500 + default: + code = 500 + } + + if code != 200 { + ash.WriteError(w, 500, err) + return + } + + w.WriteHeader(200) + w.Header().Set("Content-Type", "text/plain") + rqs, err = ash.Output(rqs) + if err != nil { + ash.WriteError(w, 500, err) + return + } + + rqs, err = ash.Reset(rqs) + if err != nil { + ash.WriteError(w, 500, err) + return + } +} + +func (ash *ATSessionHandler) Output(rqs request.RequestSession) (request.RequestSession, error) { + var err error + var prefix string + + if rqs.Continue { + prefix = "CON " + } else { + prefix = "END " + } + + _, err = io.WriteString(rqs.Writer, prefix) + if err != nil { + return rqs, err + } + + _, err = rqs.Engine.Flush(rqs.Ctx, rqs.Writer) + return rqs, err +} diff --git a/internal/africastalking/server_test.go b/internal/africastalking/server_test.go new file mode 100644 index 0000000..dd45c25 --- /dev/null +++ b/internal/africastalking/server_test.go @@ -0,0 +1,234 @@ +package at + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "git.defalsify.org/vise.git/engine" + "git.grassecon.net/urdt/ussd/internal/handlers" + "git.grassecon.net/urdt/ussd/internal/testutil/mocks/httpmocks" +) + +func TestNewATSessionHandler(t *testing.T) { + mockHandler := &httpmocks.MockRequestHandler{} + ash := NewATSessionHandler(mockHandler) + + if ash == nil { + t.Fatal("NewATSessionHandler returned nil") + } + + if ash.SessionHandler == nil { + t.Fatal("SessionHandler is nil") + } +} + +func TestATSessionHandler_ServeHTTP(t *testing.T) { + tests := []struct { + name string + setupMocks func(*httpmocks.MockRequestHandler, *httpmocks.MockRequestParser, *httpmocks.MockEngine) + formData url.Values + expectedStatus int + expectedBody string + }{ + { + name: "Successful request", + setupMocks: func(mh *httpmocks.MockRequestHandler, mrp *httpmocks.MockRequestParser, me *httpmocks.MockEngine) { + mrp.GetSessionIdFunc = func(rq any) (string, error) { + req := rq.(*http.Request) + return req.FormValue("phoneNumber"), nil + } + mrp.GetInputFunc = func(rq any) ([]byte, error) { + req := rq.(*http.Request) + text := req.FormValue("text") + parts := strings.Split(text, "*") + return []byte(parts[len(parts)-1]), nil + } + mh.ProcessFunc = func(rqs handlers.RequestSession) (handlers.RequestSession, error) { + rqs.Continue = true + rqs.Engine = me + return rqs, nil + } + mh.GetConfigFunc = func() engine.Config { return engine.Config{} } + mh.GetRequestParserFunc = func() handlers.RequestParser { return mrp } + mh.OutputFunc = func(rs handlers.RequestSession) (handlers.RequestSession, error) { return rs, nil } + mh.ResetFunc = func(rs handlers.RequestSession) (handlers.RequestSession, error) { return rs, nil } + me.FlushFunc = func(context.Context, io.Writer) (int, error) { return 0, nil } + }, + formData: url.Values{ + "phoneNumber": []string{"+1234567890"}, + "text": []string{"1*2*3"}, + }, + expectedStatus: http.StatusOK, + expectedBody: "CON ", + }, + { + name: "GetSessionId error", + setupMocks: func(mh *httpmocks.MockRequestHandler, mrp *httpmocks.MockRequestParser, me *httpmocks.MockEngine) { + mrp.GetSessionIdFunc = func(rq any) (string, error) { + return "", errors.New("no phone number found") + } + mh.GetConfigFunc = func() engine.Config { return engine.Config{} } + mh.GetRequestParserFunc = func() handlers.RequestParser { return mrp } + }, + formData: url.Values{ + "text": []string{"1*2*3"}, + }, + expectedStatus: http.StatusBadRequest, + expectedBody: "", + }, + { + name: "GetInput error", + setupMocks: func(mh *httpmocks.MockRequestHandler, mrp *httpmocks.MockRequestParser, me *httpmocks.MockEngine) { + mrp.GetSessionIdFunc = func(rq any) (string, error) { + req := rq.(*http.Request) + return req.FormValue("phoneNumber"), nil + } + mrp.GetInputFunc = func(rq any) ([]byte, error) { + return nil, errors.New("no input found") + } + mh.GetConfigFunc = func() engine.Config { return engine.Config{} } + mh.GetRequestParserFunc = func() handlers.RequestParser { return mrp } + }, + formData: url.Values{ + "phoneNumber": []string{"+1234567890"}, + }, + expectedStatus: http.StatusBadRequest, + expectedBody: "", + }, + { + name: "Process error", + setupMocks: func(mh *httpmocks.MockRequestHandler, mrp *httpmocks.MockRequestParser, me *httpmocks.MockEngine) { + mrp.GetSessionIdFunc = func(rq any) (string, error) { + req := rq.(*http.Request) + return req.FormValue("phoneNumber"), nil + } + mrp.GetInputFunc = func(rq any) ([]byte, error) { + req := rq.(*http.Request) + text := req.FormValue("text") + parts := strings.Split(text, "*") + return []byte(parts[len(parts)-1]), nil + } + mh.ProcessFunc = func(rqs handlers.RequestSession) (handlers.RequestSession, error) { + return rqs, handlers.ErrStorage + } + mh.GetConfigFunc = func() engine.Config { return engine.Config{} } + mh.GetRequestParserFunc = func() handlers.RequestParser { return mrp } + }, + formData: url.Values{ + "phoneNumber": []string{"+1234567890"}, + "text": []string{"1*2*3"}, + }, + expectedStatus: http.StatusInternalServerError, + expectedBody: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockHandler := &httpmocks.MockRequestHandler{} + mockRequestParser := &httpmocks.MockRequestParser{} + mockEngine := &httpmocks.MockEngine{} + tt.setupMocks(mockHandler, mockRequestParser, mockEngine) + + ash := NewATSessionHandler(mockHandler) + + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(tt.formData.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + + ash.ServeHTTP(w, req) + + if w.Code != tt.expectedStatus { + t.Errorf("Expected status %d, got %d", tt.expectedStatus, w.Code) + } + + if tt.expectedBody != "" && w.Body.String() != tt.expectedBody { + t.Errorf("Expected body %q, got %q", tt.expectedBody, w.Body.String()) + } + }) + } +} + +func TestATSessionHandler_Output(t *testing.T) { + tests := []struct { + name string + input handlers.RequestSession + expectedPrefix string + expectedError bool + }{ + { + name: "Continue true", + input: handlers.RequestSession{ + Continue: true, + Engine: &httpmocks.MockEngine{ + FlushFunc: func(context.Context, io.Writer) (int, error) { + return 0, nil + }, + }, + Writer: &httpmocks.MockWriter{}, + }, + expectedPrefix: "CON ", + expectedError: false, + }, + { + name: "Continue false", + input: handlers.RequestSession{ + Continue: false, + Engine: &httpmocks.MockEngine{ + FlushFunc: func(context.Context, io.Writer) (int, error) { + return 0, nil + }, + }, + Writer: &httpmocks.MockWriter{}, + }, + expectedPrefix: "END ", + expectedError: false, + }, + { + name: "Flush error", + input: handlers.RequestSession{ + Continue: true, + Engine: &httpmocks.MockEngine{ + FlushFunc: func(context.Context, io.Writer) (int, error) { + return 0, errors.New("write error") + }, + }, + Writer: &httpmocks.MockWriter{}, + }, + expectedPrefix: "CON ", + expectedError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ash := &ATSessionHandler{} + _, err := ash.Output(tt.input) + + if tt.expectedError && err == nil { + t.Error("Expected an error, but got nil") + } + + if !tt.expectedError && err != nil { + t.Errorf("Unexpected error: %v", err) + } + + mw := tt.input.Writer.(*httpmocks.MockWriter) + if !mw.WriteStringCalled { + t.Error("WriteString was not called") + } + + if mw.WrittenString != tt.expectedPrefix { + t.Errorf("Expected prefix %q, got %q", tt.expectedPrefix, mw.WrittenString) + } + }) + } +} + +