This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
// Package widgetapi exposes the bounded widget catalog and preview contract.
|
||||
package widgetapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/itworx/pulse/internal/problem"
|
||||
"github.com/itworx/pulse/internal/widget"
|
||||
"github.com/itworx/pulse/internal/widgetpreview"
|
||||
)
|
||||
|
||||
type Handler struct{ Registry widget.Registry }
|
||||
|
||||
func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.Context().Err(); err != nil {
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case r.URL.Path == "/api/v1/widgets/catalog" && r.Method == http.MethodGet:
|
||||
write(w, http.StatusOK, map[string]any{"items": h.Registry.Catalog()})
|
||||
case r.URL.Path == "/api/v1/widgets/preview" && r.Method == http.MethodPost:
|
||||
contentType := strings.ToLower(strings.TrimSpace(strings.Split(r.Header.Get("Content-Type"), ";")[0]))
|
||||
if contentType != "" && contentType != "application/json" {
|
||||
problem.Write(w, r, http.StatusUnsupportedMediaType, "UNSUPPORTED_MEDIA_TYPE", "Unsupported media type", "Widget previews require application/json.", nil)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Widget map[string]any `json:"widget"`
|
||||
State string `json:"state"`
|
||||
}
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&body); err != nil || body.Widget == nil {
|
||||
problem.Write(w, r, http.StatusBadRequest, "INVALID_PREVIEW", "Invalid preview", "A bounded widget configuration is required.", nil)
|
||||
return
|
||||
}
|
||||
var trailing any
|
||||
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
||||
problem.Write(w, r, http.StatusBadRequest, "INVALID_PREVIEW", "Invalid preview", "The request must contain one JSON document.", nil)
|
||||
return
|
||||
}
|
||||
preview, err := widgetpreview.Preview(body.Widget, body.State)
|
||||
if err != nil {
|
||||
var invalid widgetpreview.InvalidConfig
|
||||
if errors.As(err, &invalid) {
|
||||
problem.Write(w, r, http.StatusBadRequest, "INVALID_WIDGET_CONFIG", "Invalid widget", "The widget configuration is invalid.", invalid.Fields)
|
||||
return
|
||||
}
|
||||
problem.Write(w, r, http.StatusBadRequest, "INVALID_WIDGET_CONFIG", "Invalid widget", "The widget configuration is invalid.", nil)
|
||||
return
|
||||
}
|
||||
write(w, http.StatusOK, map[string]any{"preview": preview})
|
||||
case r.URL.Path == "/api/v1/widgets/catalog" || r.URL.Path == "/api/v1/widgets/preview":
|
||||
problem.Write(w, r, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "Method not allowed", "The requested method is not supported for this widget route.", nil)
|
||||
default:
|
||||
problem.Write(w, r, http.StatusNotFound, "NOT_FOUND", "Not found", "The requested widget route does not exist.", nil)
|
||||
}
|
||||
}
|
||||
|
||||
func write(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package widgetapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/itworx/pulse/internal/widget"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCatalogAndPreviewContract(t *testing.T) {
|
||||
registry, err := widget.NewRegistry(widget.DefaultDefinitions())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := Handler{Registry: registry}
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/widgets/catalog", nil)
|
||||
response := httptest.NewRecorder()
|
||||
h.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("catalog=%d", response.Code)
|
||||
}
|
||||
request = httptest.NewRequest(http.MethodPost, "/api/v1/widgets/preview", nil)
|
||||
response = httptest.NewRecorder()
|
||||
h.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusBadRequest {
|
||||
t.Fatalf("invalid preview=%d", response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewValidatesBoundaries(t *testing.T) {
|
||||
registry, err := widget.NewRegistry(widget.DefaultDefinitions())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := Handler{Registry: registry}
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
contentType string
|
||||
body string
|
||||
want int
|
||||
}{
|
||||
{name: "valid", method: http.MethodPost, path: "/api/v1/widgets/preview", contentType: "application/json", body: `{"widget":{"id":"cpu","type":"stat","title":"CPU","data":{"sourceType":"semantic-metric","metric":"host.cpu.utilization"}}}`, want: http.StatusOK},
|
||||
{name: "trailing json", method: http.MethodPost, path: "/api/v1/widgets/preview", contentType: "application/json", body: `{"widget":{}} {}`, want: http.StatusBadRequest},
|
||||
{name: "unknown envelope field", method: http.MethodPost, path: "/api/v1/widgets/preview", contentType: "application/json", body: `{"widget":{},"surprise":true}`, want: http.StatusBadRequest},
|
||||
{name: "wrong content type", method: http.MethodPost, path: "/api/v1/widgets/preview", contentType: "text/plain", body: `{}`, want: http.StatusUnsupportedMediaType},
|
||||
{name: "known path wrong method", method: http.MethodDelete, path: "/api/v1/widgets/catalog", want: http.StatusMethodNotAllowed},
|
||||
{name: "unknown path", method: http.MethodGet, path: "/api/v1/widgets/missing", want: http.StatusNotFound},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
request := httptest.NewRequest(test.method, test.path, strings.NewReader(test.body))
|
||||
if test.contentType != "" {
|
||||
request.Header.Set("Content-Type", test.contentType)
|
||||
}
|
||||
response := httptest.NewRecorder()
|
||||
h.ServeHTTP(response, request)
|
||||
if response.Code != test.want {
|
||||
t.Fatalf("status=%d want=%d body=%s", response.Code, test.want, response.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanceledRequestDoesNotWrite(t *testing.T) {
|
||||
registry, err := widget.NewRegistry(widget.DefaultDefinitions())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/widgets/catalog", nil).WithContext(ctx)
|
||||
response := httptest.NewRecorder()
|
||||
(Handler{Registry: registry}).ServeHTTP(response, request)
|
||||
if response.Body.Len() != 0 {
|
||||
t.Fatalf("canceled request wrote %q", response.Body.String())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user