Skip to content

Commit

Permalink
feat: API server
Browse files Browse the repository at this point in the history
  • Loading branch information
mojtaba-esk committed Nov 29, 2023
1 parent 8bb01d9 commit 790253c
Show file tree
Hide file tree
Showing 25 changed files with 1,293 additions and 24 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@ go.work
bin/
*.o
run.sh
.vscode/
7 changes: 3 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,8 @@ build:
docker:
docker build -t bittwister .

# `run` is used to ease the developer life
run: all
sudo ./bin/$(BINARY_NAME) start -d wlp3s0 -b 500
test-go:
sudo go test -v ./... -count=1 -p=1

test-packetloss:
@bash ./scripts/tests/packetloss.sh
Expand All @@ -27,6 +26,6 @@ test-latency:
test-jitter:
@bash ./scripts/tests/jitter.sh

test: test-packetloss test-bandwidth test-latency test-jitter
test: test-go test-packetloss test-bandwidth test-latency test-jitter

.PHONY: all generate build run test
83 changes: 83 additions & 0 deletions api/v1/api.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package api

import (
"context"
"errors"
"fmt"
"net/http"

"github.com/gorilla/handlers"
"github.com/gorilla/mux"
"go.uber.org/zap"
)

func path(endpoint string) string {
return fmt.Sprintf("/api/v1%s", endpoint)
}

func NewRESTApiV1(productionMode bool, logger *zap.Logger) *RESTApiV1 {
restAPI := &RESTApiV1{
router: mux.NewRouter(),
logger: logger,
loggerNoStack: logger.WithOptions(zap.AddStacktrace(zap.DPanicLevel)),
productionMode: productionMode,
}

restAPI.router.HandleFunc("/", restAPI.IndexPage).Methods(http.MethodGet, http.MethodPost, http.MethodOptions, http.MethodPut, http.MethodHead)

restAPI.router.HandleFunc(path("/packetloss/start"), restAPI.PacketlossStart).Methods(http.MethodPost)
restAPI.router.HandleFunc(path("/packetloss/status"), restAPI.PacketlossStatus).Methods(http.MethodGet)
restAPI.router.HandleFunc(path("/packetloss/stop"), restAPI.PacketlossStop).Methods(http.MethodPost)

restAPI.router.HandleFunc(path("/bandwidth/start"), restAPI.BandwidthStart).Methods(http.MethodPost)
restAPI.router.HandleFunc(path("/bandwidth/status"), restAPI.BandwidthStatus).Methods(http.MethodGet)
restAPI.router.HandleFunc(path("/bandwidth/stop"), restAPI.BandwidthStop).Methods(http.MethodPost)

restAPI.router.HandleFunc(path("/latency/start"), restAPI.LatencyStart).Methods(http.MethodPost)
restAPI.router.HandleFunc(path("/latency/status"), restAPI.LatencyStatus).Methods(http.MethodGet)
restAPI.router.HandleFunc(path("/latency/stop"), restAPI.LatencyStop).Methods(http.MethodPost)

restAPI.router.HandleFunc(path("/services/status"), restAPI.NetServicesStatus).Methods(http.MethodGet)

return restAPI
}

func (a *RESTApiV1) Serve(addr, originAllowed string) error {
http.Handle("/", a.router)

headersOk := handlers.AllowedHeaders([]string{"X-Requested-With", "Content-Type", "Content-Length", "Accept-Encoding", "Authorization", "X-CSRF-Token"})
originsOk := handlers.AllowedOrigins([]string{originAllowed})
methodsOk := handlers.AllowedMethods([]string{"GET", "HEAD", "POST", "PUT", "OPTIONS"})

a.logger.Info(fmt.Sprintf("serving on %s", addr))

a.server = &http.Server{
Addr: addr,
Handler: handlers.CORS(originsOk, headersOk, methodsOk)(a.router),
}

return a.server.ListenAndServe()
}

func (a *RESTApiV1) Shutdown() error {
if a.server == nil {
return errors.New("server is not running")
}
return a.server.Shutdown(context.Background())
}

func (a *RESTApiV1) GetAllAPIs() []string {
list := []string{}
err := a.router.Walk(func(route *mux.Route, router *mux.Router, ancestors []*mux.Route) error {
apiPath, err := route.GetPathTemplate()
if err == nil {
list = append(list, apiPath)
}
return err
})
if err != nil {
a.logger.Error("error while getting all APIs", zap.Error(err))
}

return list
}
67 changes: 67 additions & 0 deletions api/v1/bandwidth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package api

import (
"encoding/json"
"net/http"

"github.com/celestiaorg/bittwister/xdp/bandwidth"
"go.uber.org/zap"
)

// BandwidthStart implements POST /bandwidth/start
func (a *RESTApiV1) BandwidthStart(resp http.ResponseWriter, req *http.Request) {
var body BandwidthStartRequest
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
sendJSONError(resp,
MetaMessage{
Type: APIMetaMessageTypeError,
Slug: SlugJSONDecodeFailed,
Title: "JSON decode failed",
Message: err.Error(),
},
http.StatusBadRequest)
return
}

if a.bw == nil {
a.bw = &netRestrictService{
service: &bandwidth.Bandwidth{
Limit: body.Limit,
},
logger: a.logger,
}
} else {
bw, ok := a.bw.service.(*bandwidth.Bandwidth)
if !ok {
sendJSONError(resp,
MetaMessage{
Type: APIMetaMessageTypeError,
Slug: SlugTypeError,
Title: "Type cast error",
Message: "could not cast netRestrictService.service to *packetloss.PacketLoss",
},
http.StatusInternalServerError)
return
}
bw.Limit = body.Limit
}

err := netServiceStart(resp, a.bw, body.NetworkInterfaceName)
if err != nil {
a.loggerNoStack.Error("netServiceStart failed", zap.Error(err))
}
}

// BandwidthStop implements POST /bandwidth/stop
func (a *RESTApiV1) BandwidthStop(resp http.ResponseWriter, req *http.Request) {
if err := netServiceStop(resp, a.bw); err != nil {
a.loggerNoStack.Error("netServiceStop failed", zap.Error(err))
}
}

// BandwidthStatus implements GET /bandwidth/status
func (a *RESTApiV1) BandwidthStatus(resp http.ResponseWriter, _ *http.Request) {
if err := netServiceStatus(resp, a.bw); err != nil {
a.loggerNoStack.Error("netServiceStatus failed", zap.Error(err))
}
}
96 changes: 96 additions & 0 deletions api/v1/bandwidth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package api_test

import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"

api "github.com/celestiaorg/bittwister/api/v1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func (s *APITestSuite) TestBandwidthStart() {
t := s.T()

reqBody := api.BandwidthStartRequest{
NetworkInterfaceName: s.ifaceName,
Limit: 100,
}
jsonBody, err := json.Marshal(reqBody)
require.NoError(t, err)

req, err := http.NewRequest(http.MethodPost, "/api/v1/bandwidth/start", bytes.NewReader(jsonBody))
require.NoError(t, err)

rr := httptest.NewRecorder()
s.restAPI.BandwidthStart(rr, req)
// need to stop it to release the network interface for other tests
defer s.restAPI.BandwidthStop(rr, nil)

assert.Equal(t, http.StatusOK, rr.Code)
}

func (s *APITestSuite) TestBandwidthStop() {
t := s.T()

reqBody := api.BandwidthStartRequest{
NetworkInterfaceName: s.ifaceName,
Limit: 100,
}
jsonBody, err := json.Marshal(reqBody)
require.NoError(t, err)

req, err := http.NewRequest(http.MethodPost, "/api/v1/bandwidth/start", bytes.NewReader(jsonBody))
require.NoError(t, err)

rr := httptest.NewRecorder()
s.restAPI.BandwidthStart(rr, req)

require.NoError(t, waitForService(s.restAPI.BandwidthStatus))

rr = httptest.NewRecorder()
s.restAPI.BandwidthStop(rr, nil)
require.Equal(t, http.StatusOK, rr.Code)

slug, err := getServiceStatusSlug(s.restAPI.BandwidthStatus)
require.NoError(t, err)
assert.Equal(t, api.SlugServiceNotReady, slug)
}

func (s *APITestSuite) TestBandwidthStatus() {
t := s.T()

slug, err := getServiceStatusSlug(s.restAPI.BandwidthStatus)
require.NoError(t, err)
if slug != api.SlugServiceNotReady && slug != api.SlugServiceNotInitialized {
t.Fatalf("unexpected service status: %s", slug)
}

reqBody := api.BandwidthStartRequest{
NetworkInterfaceName: s.ifaceName,
Limit: 100,
}
jsonBody, err := json.Marshal(reqBody)
require.NoError(t, err)

req, err := http.NewRequest(http.MethodPost, "/api/v1/bandwidth/start", bytes.NewReader(jsonBody))
require.NoError(t, err)

rr := httptest.NewRecorder()
s.restAPI.BandwidthStart(rr, req)

require.NoError(t, waitForService(s.restAPI.BandwidthStatus))

slug, err = getServiceStatusSlug(s.restAPI.BandwidthStatus)
require.NoError(t, err)
assert.Equal(t, api.SlugServiceReady, slug)

s.restAPI.BandwidthStop(rr, nil)
require.Equal(t, http.StatusOK, rr.Code)

slug, err = getServiceStatusSlug(s.restAPI.BandwidthStatus)
require.NoError(t, err)
assert.Equal(t, api.SlugServiceNotReady, slug)
}
48 changes: 48 additions & 0 deletions api/v1/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package api

import (
"errors"
)

const (
APIMetaMessageTypeInfo = "info"
APIMetaMessageTypeWarning = "warning"
APIMetaMessageTypeError = "error"
)

const (
SlugServiceAlreadyStarted = "service-already-started"
SlugServiceStartFailed = "service-start-failed"
SlugServiceStopFailed = "service-stop-failed"
SlugServiceNotStarted = "service-not-started"
SlugServiceNotInitialized = "service-not-initialized"
SlugServiceReady = "service-ready"
SlugServiceNotReady = "service-not-ready"
SlugJSONDecodeFailed = "json-decode-failed"
SlugTypeError = "type-error"
)

type MetaMessage struct {
Type string `json:"type"` // info, warning, error
Slug string `json:"slug"`
Title string `json:"title"`
Message string `json:"message"`
}

var (
ErrServiceNotInitialized = errors.New(SlugServiceNotInitialized)
ErrServiceAlreadyStarted = errors.New(SlugServiceAlreadyStarted)
ErrServiceNotStarted = errors.New(SlugServiceNotStarted)
ErrServiceStopFailed = errors.New(SlugServiceStopFailed)
ErrServiceStartFailed = errors.New(SlugServiceStartFailed)
)

// convert a ApiMetaMessage to map[string]interface{}
func (m MetaMessage) ToMap() map[string]interface{} {
return map[string]interface{}{
"type": m.Type,
"slug": m.Slug,
"title": m.Title,
"message": m.Message,
}
}
63 changes: 63 additions & 0 deletions api/v1/index.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package api

import (
"fmt"
"net/http"
"os"
"runtime/debug"
"strings"
)

// IndexPage implements GET /
func (a *RESTApiV1) IndexPage(resp http.ResponseWriter, _ *http.Request) {
if a.productionMode {
return
}

modName := "unknown"
buildInfo := ""
if bi, ok := debug.ReadBuildInfo(); ok {
modName = bi.Path

buildInfo += "<br /><h3>Build Info:</h3><table>"
for _, s := range bi.Settings {
buildInfo += fmt.Sprintf("<tr><td>%s</td><td>%s</td></tr>", s.Key, s.Value)
}
buildInfo += "</table>"
}

html := `<!DOCTYPE html><html><head><style>
table {border-collapse: collapse; width: 100%;}
td, th {border: 1px solid #222;text-align: left; padding: 8px;}
tr:nth-child(even) {background-color: #222;}
a {
text-decoration:none;border-bottom: 2px solid #10747f;
color: #f1ff8f;transition: background 0.1s cubic-bezier(.33,.66,.66,1);
}
a:hover {background: #10747f;}
body {
color: #FFF; font-family: sans-serif;
justify-content: center;align-items: center;
line-height:1.8;margin:0;padding:0 40px;
background-image: linear-gradient(135deg, rgba(0, 0, 0, 0.85) 0%,rgba(0, 0, 0,1) 100%);
}
</style></head><body>`

html += fmt.Sprintf("Ciao, this is `%v` \n\n<p>", modName)
allAPIs := a.GetAllAPIs()
html += "<h3>List of endpoints:</h3>"
for _, a := range allAPIs {

href := strings.TrimPrefix(a, "/") // it fixes the links if the service is running under a path
html += fmt.Sprintf(`<a href="%s">%s</a><br />`, href, a)
}

html += fmt.Sprintf("<br />Production Mode: %v", os.Getenv("PRODUCTION_MODE"))
html += buildInfo

resp.Header().Set("Content-Type", "text/html; charset=utf-8")
_, err := resp.Write([]byte(html))
if err != nil {
a.logger.Error(fmt.Sprintf("api `IndexPage`: %v", err))
}
}
Loading

0 comments on commit 790253c

Please sign in to comment.