• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

gameap / gameap / 29667068588

19 Jul 2026 12:30AM UTC coverage: 82.155% (-0.005%) from 82.16%
29667068588

push

github

web-flow
Plugin fixes (#28)

* plugin fixes

* review, SERVER_DELETED before SERVER_POST_DELETE fix

* review, prev status fix

* review, limit async dispatches

* review, fix manager and plugin id

* review, busy plugin

* ci logging fixes

* plugin load fix

* fix status transitions

349 of 436 new or added lines in 24 files covered. (80.05%)

3 existing lines in 2 files now uncovered.

47365 of 57653 relevant lines covered (82.16%)

34725.02 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

95.92
/pkg/plugin/http_handler.go
1
package plugin
2

3
import (
4
        "context"
5
        "io"
6
        "log/slog"
7
        "net/http"
8
        "strings"
9
        "time"
10

11
        "github.com/gameap/gameap/internal/domain"
12
        "github.com/gameap/gameap/pkg/auth"
13
        "github.com/gameap/gameap/pkg/plugin/proto"
14
        gameapProto "github.com/gameap/gameap/pkg/proto"
15
        "github.com/gorilla/mux"
16
        "github.com/pkg/errors"
17
)
18

19
const (
20
        DefaultTimeout     = 30 * time.Second
21
        DefaultMaxBodySize = 1 << 20 // 1MB
22
)
23

24
type Middleware interface {
25
        Middleware(next http.Handler) http.Handler
26
}
27

28
// HTTPHandler handles HTTP requests for plugins.
29
type HTTPHandler struct {
30
        manager         *Manager
31
        authMiddleware  Middleware
32
        adminMiddleware Middleware
33
        timeout         time.Duration
34
        maxBody         int64
35
}
36

37
// NewHTTPHandler creates a new HTTP handler for plugin routes.
38
func NewHTTPHandler(
39
        manager *Manager,
40
        authMiddleware Middleware,
41
        adminMiddleware Middleware,
42
) *HTTPHandler {
9✔
43
        return &HTTPHandler{
9✔
44
                manager:         manager,
9✔
45
                authMiddleware:  authMiddleware,
9✔
46
                adminMiddleware: adminMiddleware,
9✔
47
                timeout:         DefaultTimeout,
9✔
48
                maxBody:         DefaultMaxBodySize,
9✔
49
        }
9✔
50
}
9✔
51

52
// ServeHTTP handles HTTP requests for plugin routes.
53
func (h *HTTPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
8✔
54
        vars := mux.Vars(r)
8✔
55
        pluginID := vars["plugin_id"]
8✔
56

8✔
57
        if pluginID == "" {
9✔
58
                http.Error(w, "plugin ID is required", http.StatusBadRequest)
1✔
59

1✔
60
                return
1✔
61
        }
1✔
62

63
        // Normalize plugin ID
64
        pluginID = CompactPluginID(ParsePluginID(pluginID))
7✔
65

7✔
66
        plugin, ok := h.manager.GetPlugin(pluginID)
7✔
67
        if !ok {
8✔
68
                http.NotFound(w, r)
1✔
69

1✔
70
                return
1✔
71
        }
1✔
72

73
        if !plugin.IsEnabled() {
7✔
74
                http.Error(w, "plugin is disabled", http.StatusServiceUnavailable)
1✔
75

1✔
76
                return
1✔
77
        }
1✔
78

79
        pluginPath := extractPluginPath(r.URL.Path, pluginID)
5✔
80

5✔
81
        route, pathParams := h.matchRoute(plugin, r.Method, pluginPath)
5✔
82
        if route == nil {
6✔
83
                http.Error(w, "route not found", http.StatusNotFound)
1✔
84

1✔
85
                return
1✔
86
        }
1✔
87

88
        handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
8✔
89
                h.handlePluginRequest(w, r, plugin, pluginPath, pathParams)
4✔
90
        })
4✔
91

92
        var finalHandler http.Handler = handler
4✔
93

4✔
94
        if route.AdminOnly {
6✔
95
                finalHandler = h.adminMiddleware.Middleware(finalHandler)
2✔
96
        }
2✔
97

98
        if route.RequiresAuth {
6✔
99
                finalHandler = h.authMiddleware.Middleware(finalHandler)
2✔
100
        }
2✔
101

102
        finalHandler.ServeHTTP(w, r)
4✔
103
}
104

105
func (h *HTTPHandler) handlePluginRequest(
106
        w http.ResponseWriter,
107
        r *http.Request,
108
        plugin *LoadedPlugin,
109
        pluginPath string,
110
        pathParams map[string]string,
111
) {
8✔
112
        ctx := r.Context()
8✔
113

8✔
114
        protoReq, err := h.buildProtoRequest(r, plugin.Info.Id, pluginPath, pathParams)
8✔
115
        if err != nil {
9✔
116
                //nolint:gosec // G706: slog structured logging safely encodes values
1✔
117
                slog.Error("failed to build proto request",
1✔
118
                        slog.String("plugin_id", plugin.Info.Id),
1✔
119
                        slog.String("error", err.Error()),
1✔
120
                )
1✔
121
                http.Error(w, "failed to process request", http.StatusBadRequest)
1✔
122

1✔
123
                return
1✔
124
        }
1✔
125

126
        ctx, cancel := context.WithTimeout(ctx, h.timeout)
7✔
127
        defer cancel()
7✔
128

7✔
129
        resp, err := h.callPlugin(ctx, plugin, protoReq)
7✔
130
        if err != nil {
9✔
131
                //nolint:gosec // G706: slog structured logging safely encodes values
2✔
132
                slog.Error("plugin request failed",
2✔
133
                        slog.String("plugin_id", plugin.Info.Id),
2✔
134
                        slog.String("path", pluginPath),
2✔
135
                        slog.String("error", err.Error()),
2✔
136
                )
2✔
137

2✔
138
                if errors.Is(err, ErrPluginBusy) {
2✔
NEW
139
                        // The guest was never invoked; the plugin stays enabled.
×
NEW
140
                        http.Error(w, "plugin is busy", http.StatusServiceUnavailable)
×
NEW
141

×
NEW
142
                        return
×
NEW
143
                }
×
144

145
                if errors.Is(ctx.Err(), context.DeadlineExceeded) || errors.Is(err, context.DeadlineExceeded) {
3✔
146
                        // The runtime closed the module on deadline; stop routing to it.
1✔
147
                        plugin.Disable()
1✔
148

1✔
149
                        slog.Error("plugin HTTP handler timed out, plugin disabled until reload",
1✔
150
                                slog.String("plugin_id", plugin.Info.Id),
1✔
151
                        )
1✔
152
                        http.Error(w, "request timeout", http.StatusGatewayTimeout)
1✔
153

1✔
154
                        return
1✔
155
                }
1✔
156

157
                http.Error(w, "plugin error", http.StatusInternalServerError)
1✔
158

1✔
159
                return
1✔
160
        }
161

162
        h.writeResponse(w, resp)
5✔
163
}
164

165
func extractPluginPath(fullPath, pluginID string) string {
10✔
166
        prefix := "/api/plugins/" + pluginID
10✔
167
        if after, ok := strings.CutPrefix(fullPath, prefix); ok {
18✔
168
                path := after
8✔
169
                if path == "" {
9✔
170
                        return "/"
1✔
171
                }
1✔
172

173
                return path
7✔
174
        }
175

176
        return "/"
2✔
177
}
178

179
func (h *HTTPHandler) matchRoute(
180
        plugin *LoadedPlugin,
181
        method string,
182
        path string,
183
) (*proto.HTTPRoute, map[string]string) {
11✔
184
        for _, route := range plugin.HTTPRoutes {
21✔
185
                if !containsMethod(route.Methods, method) {
11✔
186
                        continue
1✔
187
                }
188

189
                pathParams, ok := matchPath(route.Path, path)
9✔
190
                if ok {
17✔
191
                        return route, pathParams
8✔
192
                }
8✔
193
        }
194

195
        return nil, nil
3✔
196
}
197

198
func containsMethod(methods []string, method string) bool {
15✔
199
        for _, m := range methods {
29✔
200
                if strings.EqualFold(m, method) {
25✔
201
                        return true
11✔
202
                }
11✔
203
        }
204

205
        return false
4✔
206
}
207

208
func matchPath(pattern, path string) (map[string]string, bool) {
16✔
209
        patternParts := strings.Split(strings.Trim(pattern, "/"), "/")
16✔
210
        pathParts := strings.Split(strings.Trim(path, "/"), "/")
16✔
211

16✔
212
        if pattern == "/" && path == "/" {
17✔
213
                return map[string]string{}, true
1✔
214
        }
1✔
215

216
        if len(patternParts) != len(pathParts) {
16✔
217
                return nil, false
1✔
218
        }
1✔
219

220
        params := make(map[string]string)
14✔
221
        for i, patternPart := range patternParts {
35✔
222
                pathPart := pathParts[i]
21✔
223

21✔
224
                if strings.HasPrefix(patternPart, "{") && strings.HasSuffix(patternPart, "}") {
25✔
225
                        paramName := patternPart[1 : len(patternPart)-1]
4✔
226
                        params[paramName] = pathPart
4✔
227
                } else if patternPart != pathPart {
23✔
228
                        return nil, false
2✔
229
                }
2✔
230
        }
231

232
        return params, true
12✔
233
}
234

235
func (h *HTTPHandler) buildProtoRequest(
236
        r *http.Request,
237
        pluginID string,
238
        pluginPath string,
239
        pathParams map[string]string,
240
) (*proto.HTTPRequest, error) {
15✔
241
        body, err := h.readBody(r)
15✔
242
        if err != nil {
17✔
243
                return nil, errors.WithMessage(err, "failed to read request body")
2✔
244
        }
2✔
245

246
        headers := make(map[string]string)
13✔
247
        for key, values := range r.Header {
15✔
248
                if len(values) > 0 {
4✔
249
                        headers[key] = values[0]
2✔
250
                }
2✔
251
        }
252

253
        queryParams := make(map[string]*proto.QueryParamValues)
13✔
254
        for key, values := range r.URL.Query() {
16✔
255
                expandedValues := expandQueryValues(values)
3✔
256
                queryParams[key] = &proto.QueryParamValues{
3✔
257
                        Values: expandedValues,
3✔
258
                }
3✔
259
        }
3✔
260

261
        session := h.buildProtoSession(r.Context())
13✔
262

13✔
263
        return &proto.HTTPRequest{
13✔
264
                Context: &proto.PluginContext{
13✔
265
                        PluginId:  pluginID,
13✔
266
                        RequestId: r.Header.Get("X-Request-ID"),
13✔
267
                },
13✔
268
                Method:      r.Method,
13✔
269
                Path:        pluginPath,
13✔
270
                Headers:     headers,
13✔
271
                PathParams:  pathParams,
13✔
272
                QueryParams: queryParams,
13✔
273
                Body:        body,
13✔
274
                Session:     session,
13✔
275
        }, nil
13✔
276
}
277

278
func (h *HTTPHandler) readBody(r *http.Request) ([]byte, error) {
20✔
279
        if r.Body == nil {
21✔
280
                return nil, nil
1✔
281
        }
1✔
282

283
        limitedReader := io.LimitReader(r.Body, h.maxBody+1)
19✔
284
        body, err := io.ReadAll(limitedReader)
19✔
285
        if err != nil {
19✔
286
                return nil, errors.Wrap(err, "failed to read body")
×
287
        }
×
288

289
        if int64(len(body)) > h.maxBody {
22✔
290
                return nil, errors.New("request body too large")
3✔
291
        }
3✔
292

293
        return body, nil
16✔
294
}
295

296
func (h *HTTPHandler) buildProtoSession(ctx context.Context) *proto.Session {
19✔
297
        authSession := auth.SessionFromContext(ctx)
19✔
298
        if authSession == nil || !authSession.IsAuthenticated() {
33✔
299
                return nil
14✔
300
        }
14✔
301

302
        protoSession := &proto.Session{
5✔
303
                Id: authSession.ID,
5✔
304
                User: &gameapProto.User{
5✔
305
                        Id:    uint64(authSession.User.ID),
5✔
306
                        Login: authSession.User.Login,
5✔
307
                        Email: authSession.User.Email,
5✔
308
                        Name:  authSession.User.Name,
5✔
309
                },
5✔
310
        }
5✔
311

5✔
312
        if authSession.User.CreatedAt != nil {
7✔
313
                protoSession.User.CreatedAt = new(authSession.User.CreatedAt.Unix())
2✔
314
        }
2✔
315
        if authSession.User.UpdatedAt != nil {
7✔
316
                protoSession.User.UpdatedAt = new(authSession.User.UpdatedAt.Unix())
2✔
317
        }
2✔
318

319
        if authSession.IsTokenSession() {
6✔
320
                protoSession.Token = buildProtoToken(authSession.Token)
1✔
321
        }
1✔
322

323
        return protoSession
5✔
324
}
325

326
func buildProtoToken(token *domain.PersonalAccessToken) *gameapProto.PersonalAccessToken {
7✔
327
        if token == nil {
8✔
328
                return nil
1✔
329
        }
1✔
330

331
        protoToken := &gameapProto.PersonalAccessToken{
6✔
332
                Id:          uint64(token.ID),
6✔
333
                TokenableId: uint64(token.TokenableID),
6✔
334
                Name:        token.Name,
6✔
335
        }
6✔
336

6✔
337
        protoToken.TokenableType = domainEntityTypeToProto(token.TokenableType)
6✔
338

6✔
339
        if token.Abilities != nil {
8✔
340
                abilities := make([]string, 0, len(*token.Abilities))
2✔
341
                for _, ability := range *token.Abilities {
6✔
342
                        abilities = append(abilities, string(ability))
4✔
343
                }
4✔
344
                protoToken.Abilities = abilities
2✔
345
        }
346

347
        if token.LastUsedAt != nil {
7✔
348
                protoToken.LastUsedAt = new(token.LastUsedAt.Unix())
1✔
349
        }
1✔
350
        if token.CreatedAt != nil {
7✔
351
                protoToken.CreatedAt = new(token.CreatedAt.Unix())
1✔
352
        }
1✔
353

354
        return protoToken
6✔
355
}
356

357
func (h *HTTPHandler) callPlugin(
358
        ctx context.Context,
359
        plugin *LoadedPlugin,
360
        req *proto.HTTPRequest,
361
) (*proto.HTTPResponse, error) {
7✔
362
        resp, err := plugin.Instance.HandleHTTPRequest(ctx, req)
7✔
363
        if err != nil {
9✔
364
                return nil, errors.Wrap(err, "plugin HandleHTTPRequest failed")
2✔
365
        }
2✔
366

367
        return resp, nil
5✔
368
}
369

370
func (h *HTTPHandler) writeResponse(w http.ResponseWriter, resp *proto.HTTPResponse) {
12✔
371
        for key, value := range resp.Headers {
15✔
372
                w.Header().Set(key, value)
3✔
373
        }
3✔
374

375
        if w.Header().Get("Content-Type") == "" {
22✔
376
                w.Header().Set("Content-Type", "application/json")
10✔
377
        }
10✔
378

379
        statusCode := int(resp.StatusCode)
12✔
380
        if statusCode == 0 {
13✔
381
                statusCode = http.StatusOK
1✔
382
        }
1✔
383
        w.WriteHeader(statusCode)
12✔
384

12✔
385
        if len(resp.Body) > 0 {
15✔
386
                //nolint:gosec // G705: resp.Body is from trusted plugin, Content-Type is set
3✔
387
                _, err := w.Write(resp.Body)
3✔
388
                if err != nil {
3✔
389
                        //nolint:gosec // G706: slog structured logging safely encodes values
×
390
                        slog.Error("failed to write response body",
×
391
                                slog.String("error", err.Error()),
×
392
                        )
×
393
                }
×
394
        }
395
}
396

397
func expandQueryValues(values []string) []string {
8✔
398
        result := make([]string, 0, len(values))
8✔
399
        for _, value := range values {
15✔
400
                if strings.Contains(value, ",") {
10✔
401
                        parts := strings.Split(value, ",")
3✔
402
                        result = append(result, parts...)
3✔
403
                } else {
7✔
404
                        result = append(result, value)
4✔
405
                }
4✔
406
        }
407

408
        return result
8✔
409
}
410

411
func domainEntityTypeToProto(entityType domain.EntityType) gameapProto.EntityType {
15✔
412
        switch entityType {
15✔
413
        case domain.EntityTypeUser:
2✔
414
                return gameapProto.EntityType_ENTITY_TYPE_USER
2✔
415
        case domain.EntityTypeNode:
1✔
416
                return gameapProto.EntityType_ENTITY_TYPE_NODE
1✔
417
        case domain.EntityTypeClientCertificate:
1✔
418
                return gameapProto.EntityType_ENTITY_TYPE_CLIENT_CERTIFICATE
1✔
419
        case domain.EntityTypeGame:
1✔
420
                return gameapProto.EntityType_ENTITY_TYPE_GAME
1✔
421
        case domain.EntityTypeGameMod:
1✔
422
                return gameapProto.EntityType_ENTITY_TYPE_GAME_MOD
1✔
423
        case domain.EntityTypeServer:
1✔
424
                return gameapProto.EntityType_ENTITY_TYPE_SERVER
1✔
425
        case domain.EntityTypeRole:
1✔
426
                return gameapProto.EntityType_ENTITY_TYPE_ROLE
1✔
427
        default:
7✔
428
                return gameapProto.EntityType_ENTITY_TYPE_UNSPECIFIED
7✔
429
        }
430
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc