Skip to content

Commit 220e782

Browse files
committed
feat: add API endpoints
Signed-off-by: Ales Verbic <[email protected]>
1 parent 13b7858 commit 220e782

File tree

7 files changed

+499
-0
lines changed

7 files changed

+499
-0
lines changed

api/api.go

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
package api
2+
3+
import (
4+
"encoding/json"
5+
"log"
6+
"sync"
7+
"time"
8+
9+
"github.com/gin-gonic/gin"
10+
)
11+
12+
type API interface {
13+
Start() error
14+
AddRoute(method, path string, handler gin.HandlerFunc)
15+
}
16+
17+
type APIv1 struct {
18+
engine *gin.Engine
19+
apiGroup *gin.RouterGroup
20+
host string
21+
port string
22+
}
23+
24+
type APIRouteRegistrar interface {
25+
RegisterRoutes()
26+
}
27+
28+
type APIOption func(*APIv1)
29+
30+
func WithGroup(group string) APIOption {
31+
// Expects '/v1' as the group
32+
return func(a *APIv1) {
33+
a.apiGroup = a.engine.Group(group)
34+
}
35+
}
36+
37+
func WithHost(host string) APIOption {
38+
return func(a *APIv1) {
39+
a.host = host
40+
}
41+
}
42+
43+
func WithPort(port string) APIOption {
44+
return func(a *APIv1) {
45+
a.port = port
46+
}
47+
}
48+
49+
var apiInstance *APIv1
50+
var once sync.Once
51+
52+
func NewAPI(debug bool, options ...APIOption) *APIv1 {
53+
once.Do(func() {
54+
apiInstance = &APIv1{
55+
engine: ConfigureRouter(debug),
56+
host: "localhost",
57+
port: "8080",
58+
}
59+
for _, opt := range options {
60+
opt(apiInstance)
61+
}
62+
})
63+
return apiInstance
64+
}
65+
66+
func GetInstance() *APIv1 {
67+
return apiInstance
68+
}
69+
70+
func (a *APIv1) Engine() *gin.Engine {
71+
return a.engine
72+
}
73+
74+
func (a *APIv1) Start() error {
75+
address := a.host + ":" + a.port
76+
// Use buffered channel to not block goroutine
77+
errChan := make(chan error, 1)
78+
79+
go func() {
80+
// Capture the error returned by Run
81+
errChan <- a.engine.Run(address)
82+
}()
83+
84+
select {
85+
case err := <-errChan:
86+
return err
87+
default:
88+
// No starting errors, start server
89+
}
90+
91+
return nil
92+
}
93+
94+
func (a *APIv1) AddRoute(method, path string, handler gin.HandlerFunc) {
95+
// Inner function to add routes to a given target
96+
//(either gin.Engine or gin.RouterGroup)
97+
addRouteToTarget := func(target gin.IRoutes) {
98+
switch method {
99+
case "GET":
100+
target.GET(path, handler)
101+
case "POST":
102+
target.POST(path, handler)
103+
case "PUT":
104+
target.PUT(path, handler)
105+
case "DELETE":
106+
target.DELETE(path, handler)
107+
case "PATCH":
108+
target.PATCH(path, handler)
109+
case "HEAD":
110+
target.HEAD(path, handler)
111+
case "OPTIONS":
112+
target.OPTIONS(path, handler)
113+
default:
114+
log.Printf("Unsupported HTTP method: %s", method)
115+
}
116+
}
117+
118+
// Check if a specific apiGroup is set
119+
// If so, add the route to it. Otherwise, add to the main engine.
120+
if a.apiGroup != nil {
121+
addRouteToTarget(a.apiGroup)
122+
} else {
123+
addRouteToTarget(a.engine)
124+
}
125+
}
126+
127+
func ConfigureRouter(debug bool) *gin.Engine {
128+
if !debug {
129+
gin.SetMode(gin.ReleaseMode)
130+
}
131+
gin.DisableConsoleColor()
132+
g := gin.New()
133+
g.Use(gin.Recovery())
134+
// Custom access logging
135+
g.Use(gin.LoggerWithFormatter(accessLogger))
136+
// Healthcheck endpoint
137+
g.GET("/healthcheck", handleHealthcheck)
138+
// No-op API endpoint for testing
139+
g.GET("/ping", func(c *gin.Context) {
140+
c.String(200, "pong")
141+
})
142+
// Swagger UI
143+
// TODO: add swagger UI
144+
// g.Static("/swagger-ui", "swagger-ui")
145+
return g
146+
}
147+
148+
func accessLogger(param gin.LogFormatterParams) string {
149+
logEntry := gin.H{
150+
"type": "access",
151+
"client_ip": param.ClientIP,
152+
"timestamp": param.TimeStamp.Format(time.RFC1123),
153+
"method": param.Method,
154+
"path": param.Path,
155+
"proto": param.Request.Proto,
156+
"status_code": param.StatusCode,
157+
"latency": param.Latency,
158+
"user_agent": param.Request.UserAgent(),
159+
"error_message": param.ErrorMessage,
160+
}
161+
ret, _ := json.Marshal(logEntry)
162+
return string(ret) + "\n"
163+
}
164+
165+
func handleHealthcheck(c *gin.Context) {
166+
// TODO: add some actual health checking here
167+
c.JSON(200, gin.H{"failed": false})
168+
}

api/api_test.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package api_test
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"testing"
7+
8+
"github.com/blinklabs-io/snek/api"
9+
"github.com/blinklabs-io/snek/output/push"
10+
"github.com/stretchr/testify/assert"
11+
)
12+
13+
func TestRouteRegistration(t *testing.T) {
14+
// Initialize the API and set it to debug mode for testing
15+
apiInstance := api.NewAPI(true)
16+
17+
// Check if Fcm implements APIRouteRegistrar and register its routes
18+
// TODO: update this with actual plugin
19+
fcmPlugin := &push.Fcm{}
20+
if registrar, ok := interface{}(fcmPlugin).(api.APIRouteRegistrar); ok {
21+
registrar.RegisterRoutes()
22+
} else {
23+
t.Fatal("push.Fcm does NOT implement APIRouteRegistrar")
24+
}
25+
26+
// Create a test request to one of the registered routes
27+
req, err := http.NewRequest(http.MethodGet, "/v1/fcm/someToken", nil)
28+
if err != nil {
29+
t.Fatal(err)
30+
}
31+
32+
// Record the response
33+
rr := httptest.NewRecorder()
34+
apiInstance.Engine().ServeHTTP(rr, req)
35+
36+
// Check the status code
37+
assert.Equal(t, http.StatusNotFound, rr.Code, "Expected status not found")
38+
39+
// You can also check the response body, headers, etc.
40+
// TODO check for JSON response
41+
// assert.Equal(t, `{"fcmToken":"someToken"}`, rr.Body.String())
42+
}

cmd/snek/main.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"net/http"
2020
"os"
2121

22+
"github.com/blinklabs-io/snek/api"
2223
_ "github.com/blinklabs-io/snek/filter"
2324
_ "github.com/blinklabs-io/snek/input"
2425
"github.com/blinklabs-io/snek/internal/config"
@@ -103,6 +104,11 @@ func main() {
103104
}()
104105
}
105106

107+
// Create API instance with debug disabled
108+
apiInstance := api.NewAPI(false,
109+
api.WithGroup("/v1"),
110+
api.WithPort("8080"))
111+
106112
// Create pipeline
107113
pipe := pipeline.New()
108114

@@ -126,6 +132,11 @@ func main() {
126132
}
127133
pipe.AddOutput(output)
128134

135+
// Start API after plugins are configured
136+
if err := apiInstance.Start(); err != nil {
137+
logger.Fatalf("failed to start API: %s\n", err)
138+
}
139+
129140
// Start pipeline and wait for error
130141
if err := pipe.Start(); err != nil {
131142
logger.Fatalf("failed to start pipeline: %s\n", err)

go.mod

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ go 1.20
55
require (
66
github.com/blinklabs-io/gouroboros v0.54.0
77
github.com/gen2brain/beeep v0.0.0-20230602101333-f384c29b62dd
8+
github.com/gin-gonic/gin v1.9.1
89
github.com/kelseyhightower/envconfig v1.4.0
910
go.uber.org/zap v1.26.0
1011
gopkg.in/yaml.v2 v2.4.0
@@ -14,14 +15,43 @@ require (
1415
// replace github.com/blinklabs-io/gouroboros v0.52.0 => ../gouroboros
1516

1617
require (
18+
github.com/bytedance/sonic v1.10.1 // indirect
19+
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect
20+
github.com/chenzhuoyu/iasm v0.9.0 // indirect
21+
github.com/davecgh/go-spew v1.1.1 // indirect
1722
github.com/fxamacker/cbor/v2 v2.5.0 // indirect
23+
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
24+
github.com/gin-contrib/sse v0.1.0 // indirect
25+
github.com/go-playground/locales v0.14.1 // indirect
26+
github.com/go-playground/universal-translator v0.18.1 // indirect
27+
github.com/go-playground/validator/v10 v10.15.5 // indirect
1828
github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4 // indirect
29+
github.com/goccy/go-json v0.10.2 // indirect
1930
github.com/godbus/dbus/v5 v5.1.0 // indirect
2031
github.com/jinzhu/copier v0.4.0 // indirect
32+
github.com/json-iterator/go v1.1.12 // indirect
33+
github.com/klauspost/cpuid/v2 v2.2.5 // indirect
34+
github.com/kr/pretty v0.3.1 // indirect
35+
github.com/leodido/go-urn v1.2.4 // indirect
36+
github.com/mattn/go-isatty v0.0.19 // indirect
37+
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
38+
github.com/modern-go/reflect2 v1.0.2 // indirect
2139
github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d // indirect
40+
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
41+
github.com/pmezard/go-difflib v1.0.0 // indirect
42+
github.com/stretchr/objx v0.5.0 // indirect
43+
github.com/stretchr/testify v1.8.4 // indirect
2244
github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af // indirect
45+
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
46+
github.com/ugorji/go/codec v1.2.11 // indirect
2347
github.com/x448/float16 v0.8.4 // indirect
2448
go.uber.org/multierr v1.10.0 // indirect
49+
golang.org/x/arch v0.5.0 // indirect
2550
golang.org/x/crypto v0.13.0 // indirect
51+
golang.org/x/net v0.15.0 // indirect
2652
golang.org/x/sys v0.12.0 // indirect
53+
golang.org/x/text v0.13.0 // indirect
54+
google.golang.org/protobuf v1.31.0 // indirect
55+
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
56+
gopkg.in/yaml.v3 v3.0.1 // indirect
2757
)

0 commit comments

Comments
 (0)